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
|
# 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]
|
@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 """
|
@doc """
|
||||||
Parse ADIF string, validate, and deduplicate. Returns the same preview
|
Parse ADIF string, validate, and deduplicate. Returns the same preview
|
||||||
shape as `CsvImport.preview/2`.
|
shape as `CsvImport.preview/2`.
|
||||||
|
|
@ -144,7 +211,7 @@ defmodule Microwaveprop.Radio.AdifImport do
|
||||||
station2 = fields["CALL"]
|
station2 = fields["CALL"]
|
||||||
grid1 = normalize_grid(fields["MY_GRIDSQUARE"])
|
grid1 = normalize_grid(fields["MY_GRIDSQUARE"])
|
||||||
grid2 = normalize_grid(fields["GRIDSQUARE"])
|
grid2 = normalize_grid(fields["GRIDSQUARE"])
|
||||||
mode = fields["MODE"]
|
mode = normalize_mode(fields["MODE"], fields["SUBMODE"])
|
||||||
band = resolve_band(fields)
|
band = resolve_band(fields)
|
||||||
timestamp = parse_adif_datetime(fields["QSO_DATE"], fields["TIME_ON"])
|
timestamp = parse_adif_datetime(fields["QSO_DATE"], fields["TIME_ON"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -494,6 +494,8 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp adif_upload_form(assigns) do
|
defp adif_upload_form(assigns) do
|
||||||
|
assigns = assign(assigns, :mode_mapping, AdifImport.mode_mapping_reference())
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="alert text-sm">
|
<div class="alert text-sm">
|
||||||
|
|
@ -515,6 +517,31 @@ defmodule MicrowavepropWeb.SubmitLive do
|
||||||
</div>
|
</div>
|
||||||
</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
|
<form
|
||||||
id="adif-upload-form"
|
id="adif-upload-form"
|
||||||
phx-submit="upload_adif"
|
phx-submit="upload_adif"
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,63 @@ defmodule Microwaveprop.Radio.AdifImportTest do
|
||||||
assert row.attrs["station1"] == "W5XD"
|
assert row.attrs["station1"] == "W5XD"
|
||||||
end
|
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
|
test "parses file with no header at all" do
|
||||||
adif = """
|
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>
|
<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