prop/lib/microwaveprop/radio/adif_import.ex
Graham McIntire fc245367e3
User profiles at /u/:callsign, flexible band input, assorted UX cleanup
Profile page:
  * New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
    page showing a user's contacts and beacons. Resolves case-
    insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
    callsigns redirect to /. Uses daisyUI card / stats / table
    components with an avatar-placeholder initial and hero icons.
  * Accounts.get_user_by_callsign/1 (case-insensitive) plus
    Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
    back the page. Beacons list includes both approved and pending so
    owners see their drafts.
  * The top nav bar and the three LiveView sidebars (MapLive,
    WeatherMapLive, ContactMapLive) now render the logged-in callsign
    as a navigate link to /u/:callsign instead of a static label.
  * Nine new tests cover the lookup, the LiveView render, and the
    ownership-scoped queries.

Flexible band input:
  * New Microwaveprop.Radio.BandResolver module converts any of:
    ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
    insensitive), numeric frequency strings ("903.100", "10368.000"),
    and canonical MHz integers into the one of the site's known bands.
    Returns the nearest allowed band for numeric inputs >= 900 MHz,
    nil otherwise.
  * 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
    AdifImport.@allowed_bands, and the BandResolver list so "33cm"
    round-trips end-to-end.
  * AdifImport and CsvImport now delegate band resolution to
    BandResolver, and Radio.create_contact/2 normalizes the :band attr
    on the way in so the manual form and any API callers benefit too.
    CsvImport's "invalid band" tests previously used 99999 MHz which
    the new resolver snaps to the nearest allowed band; swapped to
    "notaband" which is truly unresolvable.

Contacts and beacons list UX:
  * Remove the "Submitted" column from /contacts — it duplicated info
    already visible on the detail page and was pushing the real
    columns off narrow viewports. submitted_cell/1 and its three
    column-specific tests go with it.
  * Hide the Lat / Lon columns from /beacons — six decimal places of
    coordinates weren't useful next to the grid square and took a
    disproportionate amount of row width.
2026-04-12 16:13:08 -05:00

368 lines
12 KiB
Elixir

defmodule Microwaveprop.Radio.AdifImport do
@moduledoc """
Parse an ADIF file into the same preview format used by CsvImport, so the
submit UI can reuse the preview/commit flow.
ADIF tagged-data format: `<FIELD_NAME:LENGTH>VALUE`
Records end with `<EOR>`. An optional header ends with `<EOH>`.
"""
import Ecto.Query, only: [from: 2]
alias Microwaveprop.Radio.BandResolver
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@dedup_window_seconds 3600
# 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`.
"""
@type preview_result :: %{
valid: [map()],
invalid: [map()],
duplicates: [map()],
total_rows: non_neg_integer(),
submitter_email: String.t()
}
@spec preview(String.t(), String.t()) :: {:ok, preview_result()} | {:error, :no_records}
def preview(adif_string, submitter_email) do
records = parse_adif(adif_string)
if records == [] do
{:error, :no_records}
else
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)
{:ok,
%{
valid: unique,
invalid: invalid,
duplicates: duplicates,
total_rows: length(records),
submitter_email: submitter_email
}}
end
end
# -- ADIF parser -----------------------------------------------------------
# Header-only ADIF tags that never appear inside QSO records
@header_only_tags ~w(ADIF_VER PROGRAMID PROGRAMVERSION CREATED_TIMESTAMP)
defp parse_adif(string) do
# Strip header if present (everything before <EOH>)
body =
case Regex.run(~r/<EOH>/i, string, return: :index) do
[{pos, len}] ->
String.slice(string, (pos + len)..-1//1)
nil ->
# No <EOH> — strip header-only tags (ADIF_VER, PROGRAMID, etc.)
# and any preceding plain text so they don't leak into the first record.
strip_header_tags(string)
end
# Split on <EOR> to get individual records
body
|> String.split(~r/<EOR>/i)
|> Enum.map(&parse_fields/1)
|> Enum.reject(&(&1 == %{}))
end
# Regex.scan with `return: :index` returns BYTE offsets. We must use
# `:binary.part/3` (byte-based) rather than `String.slice/3` (character-
# based); otherwise multi-byte UTF-8 characters earlier in the record
# (e.g. an accented NOTES field) shift every subsequent field and we
# wind up slicing the wrong substring.
defp parse_fields(record_string) do
~r/<([^:>]+):(\d+)(?::[^>]*)?>/i
|> Regex.scan(record_string, return: :index)
|> Enum.reduce(%{}, fn indices, acc ->
[{tag_start, tag_len}, {name_start, name_len}, {size_start, size_len} | _] = indices
raw_name = record_string |> :binary.part(name_start, name_len) |> String.upcase()
name = strip_app_prefix(raw_name)
size = record_string |> :binary.part(size_start, size_len) |> String.to_integer()
# Value starts right after the closing > of the tag.
value_offset = tag_start + tag_len
value = :binary.part(record_string, value_offset, size)
# Standard ADIF fields take precedence over app-defined ones
if raw_name != name and Map.has_key?(acc, name) do
acc
else
Map.put(acc, name, value)
end
end)
end
# Strip APP_PROGRAMNAME_ prefix so app-defined fields resolve to standard names.
# e.g. APP_N1MM_MYGRID → MYGRID, APP_LOGGER32_CALL → CALL
defp strip_app_prefix("APP_" <> rest) do
case String.split(rest, "_", parts: 2) do
[_program, field] -> field
_ -> "APP_" <> rest
end
end
defp strip_app_prefix(name), do: name
# Strip known header-only tags and any plain text before the first record tag.
defp strip_header_tags(string) do
Enum.reduce(@header_only_tags, string, fn tag, acc ->
Regex.replace(~r/<#{tag}:\d+(?::[^>]*)?>.*?(?=<)/is, acc, "")
end)
end
defp normalize_grid(nil), do: nil
defp normalize_grid(g), do: g |> String.trim() |> String.upcase()
# -- row building ----------------------------------------------------------
defp build_row({fields, row_num}, submitter_email) do
station1 = fields["STATION_CALLSIGN"] || fields["OPERATOR"]
station2 = fields["CALL"]
grid1 = normalize_grid(fields["MY_GRIDSQUARE"])
grid2 = normalize_grid(fields["GRIDSQUARE"])
mode = normalize_mode(fields["MODE"], fields["SUBMODE"])
band = resolve_band(fields)
timestamp = parse_adif_datetime(fields["QSO_DATE"], fields["TIME_ON"])
case {band, timestamp} do
{nil, _} ->
{:invalid, %{row_num: row_num, messages: ["could not determine a valid microwave band"]}}
{_, nil} ->
{:invalid, %{row_num: row_num, messages: ["could not parse QSO_DATE/TIME_ON"]}}
{band_str, {:ok, dt}} ->
attrs = %{
"station1" => station1,
"station2" => station2,
"grid1" => grid1,
"grid2" => grid2,
"band" => band_str,
"mode" => mode,
"qso_timestamp" => DateTime.to_iso8601(dt),
"submitter_email" => submitter_email
}
changeset = Contact.submission_changeset(%Contact{}, attrs)
if changeset.valid? do
{:parsed, %{row_num: row_num, attrs: attrs, timestamp: dt}}
else
messages = changeset_error_strings(changeset)
{:invalid, %{row_num: row_num, messages: messages}}
end
end
end
# -- band resolution -------------------------------------------------------
defp resolve_band(fields) do
cond do
freq = fields["FREQ"] -> BandResolver.resolve_as_string(freq)
band_name = fields["BAND"] -> BandResolver.resolve_as_string(band_name)
true -> nil
end
end
# -- timestamp parsing -----------------------------------------------------
defp parse_adif_datetime(nil, _time), do: nil
defp parse_adif_datetime(_date, nil), do: nil
defp parse_adif_datetime(date_str, time_str) do
with <<y::binary-4, m::binary-2, d::binary-2>> <- date_str,
{hour, min, sec} <- parse_adif_time(time_str),
{:ok, naive} <- NaiveDateTime.new(si(y), si(m), si(d), hour, min, sec) do
{:ok, DateTime.from_naive!(naive, "Etc/UTC")}
else
_ -> nil
end
end
defp parse_adif_time(<<h::binary-2, m::binary-2, s::binary-2>>), do: {si(h), si(m), si(s)}
defp parse_adif_time(<<h::binary-2, m::binary-2>>), do: {si(h), si(m), 0}
defp parse_adif_time(_), do: nil
defp si(s), do: String.to_integer(s)
# -- split / dedup (same logic as CsvImport) --------------------------------
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
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(&Decimal.new/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,
select: %{
station1: c.station1,
station2: c.station2,
grid1: c.grid1,
grid2: c.grid2,
band: c.band,
qso_timestamp: c.qso_timestamp
}
)
|> 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])
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"]
)
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)}
end
defp conflict_in_map?(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 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)}
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} ->
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