Normalize ADIF MODE/SUBMODE and document the mapping
- AdifImport.normalize_mode/2 maps ADIF MODE (and SUBMODE when present) onto the fixed set of modes the site accepts: CW/SSB/FM/FT8/FT4/Q65. SUBMODE takes precedence so `MODE=MFSK, SUBMODE=FT8` correctly imports as FT8. USB/LSB/DSB all become SSB; NBFM/WFM become FM. - Unknown modes (RTTY, PSK31, etc.) map to nil so the row still imports — mode is optional on the submission path now. - AdifImport.mode_mapping_reference/0 returns the user-facing list of recognized inputs and their mappings. - Submit page's ADIF tab renders that list in a table so users can see upfront what will happen to their file before uploading.
This commit is contained in:
parent
038f9ad54d
commit
eb1e80958c
3 changed files with 152 additions and 1 deletions
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<div class="space-y-6">
|
||||
<div class="alert text-sm">
|
||||
|
|
@ -515,6 +517,31 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert text-sm">
|
||||
<div class="w-full">
|
||||
<p class="font-semibold mb-2">Mode handling</p>
|
||||
<p class="mb-2 text-base-content/70">
|
||||
ADIF modes are normalized to the six modes we track. SUBMODE takes
|
||||
precedence when present (e.g. <code>MODE=MFSK, SUBMODE=FT8</code>
|
||||
imports as FT8).
|
||||
</p>
|
||||
<table class="table table-xs mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ADIF value</th>
|
||||
<th>Stored as</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={{adif, mapped} <- @mode_mapping}>
|
||||
<td><code>{adif}</code></td>
|
||||
<td><code>{mapped}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="adif-upload-form"
|
||||
phx-submit="upload_adif"
|
||||
|
|
|
|||
|
|
@ -230,6 +230,63 @@ defmodule Microwaveprop.Radio.AdifImportTest do
|
|||
assert row.attrs["station1"] == "W5XD"
|
||||
end
|
||||
|
||||
test "normalizes USB / LSB / DSB to SSB" do
|
||||
for sideband <- ~w(USB LSB DSB) do
|
||||
adif =
|
||||
"<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:#{byte_size(sideband)}>#{sideband}<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>"
|
||||
|
||||
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 =
|
||||
"<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:#{byte_size(variant)}>#{variant}<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>"
|
||||
|
||||
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 = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:4>MFSK<SUBMODE:3>FT8<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
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 = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:3>MFSK<SUBMODE:3>FT4<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
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 = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:4>RTTY<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
|
||||
assert row.attrs["mode"] == nil
|
||||
end
|
||||
|
||||
test "missing MODE imports as nil" do
|
||||
adif = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
|
||||
assert row.attrs["mode"] == nil
|
||||
end
|
||||
|
||||
test "parses file with no header at all" do
|
||||
adif = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue