diff --git a/lib/microwaveprop/radio/adif_import.ex b/lib/microwaveprop/radio/adif_import.ex index 936a566a..6a53131a 100644 --- a/lib/microwaveprop/radio/adif_import.ex +++ b/lib/microwaveprop/radio/adif_import.ex @@ -32,6 +32,73 @@ defmodule Microwaveprop.Radio.AdifImport do # Our allowed band centers in MHz, sorted ascending for nearest-match @allowed_bands [1296, 2304, 3456, 5760, 10_000, 24_000, 47_000, 68_000, 75_000, 122_000, 134_000, 241_000] + # Map ADIF MODE (and MODE+SUBMODE combinations) onto the fixed set of modes + # the site accepts: CW / SSB / FM / FT8 / FT4 / Q65. Unknown values map to + # nil — mode is optional on submission so unrecognized contacts still + # import, just without a mode label. + @allowed_modes ~w(CW SSB FM FT8 FT4 Q65) + @mode_map %{ + "CW" => "CW", + "PCW" => "CW", + "SSB" => "SSB", + "USB" => "SSB", + "LSB" => "SSB", + "DSB" => "SSB", + "FM" => "FM", + "NBFM" => "FM", + "WFM" => "FM", + "FT8" => "FT8", + "FT4" => "FT4", + "Q65" => "Q65" + } + + @doc """ + Every ADIF MODE / SUBMODE combination we recognize, in display order. + Used by the upload page to show users what will happen to their data + before they commit the import. + """ + @spec mode_mapping_reference() :: [{String.t(), String.t()}] + def mode_mapping_reference do + [ + {"CW / PCW", "CW"}, + {"SSB / USB / LSB / DSB", "SSB"}, + {"FM / NBFM / WFM", "FM"}, + {"FT8 (or MFSK+SUBMODE=FT8)", "FT8"}, + {"FT4 (or MFSK+SUBMODE=FT4)", "FT4"}, + {"Q65 (or MFSK+SUBMODE=Q65)", "Q65"}, + {"anything else", "(blank — row still imports)"} + ] + end + + @doc """ + Normalize an ADIF MODE / SUBMODE pair to one of our allowed modes or nil. + + ADIF convention: digital modes are often reported as `MODE=MFSK` with a + specific `SUBMODE` (FT8, FT4, Q65, JT65, …), so we prefer SUBMODE when it + matches one of our allowed values. For everything else we look up the raw + MODE in `@mode_map`; unknown values return nil. + """ + @spec normalize_mode(String.t() | nil, String.t() | nil) :: String.t() | nil + def normalize_mode(mode, submode) do + cond do + allowed = lookup_allowed(submode) -> allowed + allowed = lookup_allowed(mode) -> allowed + true -> nil + end + end + + defp lookup_allowed(nil), do: nil + + defp lookup_allowed(raw) do + normalized = raw |> to_string() |> String.trim() |> String.upcase() + + cond do + normalized == "" -> nil + normalized in @allowed_modes -> normalized + true -> Map.get(@mode_map, normalized) + end + end + @doc """ Parse ADIF string, validate, and deduplicate. Returns the same preview shape as `CsvImport.preview/2`. @@ -144,7 +211,7 @@ defmodule Microwaveprop.Radio.AdifImport do station2 = fields["CALL"] grid1 = normalize_grid(fields["MY_GRIDSQUARE"]) grid2 = normalize_grid(fields["GRIDSQUARE"]) - mode = fields["MODE"] + mode = normalize_mode(fields["MODE"], fields["SUBMODE"]) band = resolve_band(fields) timestamp = parse_adif_datetime(fields["QSO_DATE"], fields["TIME_ON"]) diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index a4c8b65c..d59f9428 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -494,6 +494,8 @@ defmodule MicrowavepropWeb.SubmitLive do end defp adif_upload_form(assigns) do + assigns = assign(assigns, :mode_mapping, AdifImport.mode_mapping_reference()) + ~H"""
@@ -515,6 +517,31 @@ defmodule MicrowavepropWeb.SubmitLive do
+
+
+

Mode handling

+

+ ADIF modes are normalized to the six modes we track. SUBMODE takes + precedence when present (e.g. MODE=MFSK, SUBMODE=FT8 + imports as FT8). +

+ + + + + + + + + + + + + +
ADIF valueStored as
{adif}{mapped}
+
+
+
W5XDK5TREM00EM1210368.000#{sideband}20260328180000" + + assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter) + assert row.attrs["mode"] == "SSB", "expected #{sideband} to map to SSB" + end + end + + test "normalizes NBFM / WFM to FM" do + for variant <- ~w(NBFM WFM) do + adif = + "W5XDK5TREM00EM1210368.000#{variant}20260328180000" + + assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter) + assert row.attrs["mode"] == "FM" + end + end + + test "uses SUBMODE when MODE is a generic digital mode" do + # ADIF convention: digital modes are reported as MODE=MFSK, SUBMODE=FT8 + adif = """ + W5XDK5TREM00EM1210368.000MFSKFT820260328180000 + """ + + assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter) + assert row.attrs["mode"] == "FT8" + end + + test "SUBMODE takes precedence when both are allowed values" do + adif = """ + W5XDK5TREM00EM1210368.000MFSKFT420260328180000 + """ + + assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter) + assert row.attrs["mode"] == "FT4" + end + + test "unknown modes are stored as nil so the row still imports" do + adif = """ + W5XDK5TREM00EM1210368.000RTTY20260328180000 + """ + + assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter) + assert row.attrs["mode"] == nil + end + + test "missing MODE imports as nil" do + adif = """ + W5XDK5TREM00EM1210368.00020260328180000 + """ + + assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter) + assert row.attrs["mode"] == nil + end + test "parses file with no header at all" do adif = """ W5XDK5TREM00EM1210368.000CW20260328180000