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.
368 lines
12 KiB
Elixir
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
|