Add ADIF file upload for contact submission
Parse ADIF tagged-data format with fuzzy frequency-to-band matching (nearest microwave band for any freq >= 900 MHz). Supports STATION_CALLSIGN, OPERATOR, CALL, GRIDSQUARE, MY_GRIDSQUARE, FREQ, BAND, QSO_DATE, TIME_ON. Same dedup and preview/commit flow as CSV upload. Also fix select dropdown text alignment in daisyUI.
This commit is contained in:
parent
1111e64bdd
commit
5bcf579009
4 changed files with 651 additions and 18 deletions
|
|
@ -105,6 +105,14 @@
|
|||
/* Make LiveView wrapper divs transparent for layout */
|
||||
[data-phx-session], [data-phx-teleported-src] { display: contents }
|
||||
|
||||
/* Fix daisyUI select text alignment — when select class is on the <select>
|
||||
element directly, the nested .select select rules cause negative margins
|
||||
and misaligned text. Reset them so text sits centered like <input>. */
|
||||
select.select {
|
||||
margin: 0;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
/* Markdown rendered content — uses daisyUI theme variables for auto dark/light */
|
||||
.markdown-content {
|
||||
max-width: 64rem;
|
||||
|
|
|
|||
286
lib/microwaveprop/radio/adif_import.ex
Normal file
286
lib/microwaveprop/radio/adif_import.ex
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
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.Contact
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
@dedup_window_seconds 3600
|
||||
|
||||
# Band plan: map ADIF band names to our MHz values
|
||||
@band_name_to_mhz %{
|
||||
"23cm" => 1296,
|
||||
"13cm" => 2304,
|
||||
"9cm" => 3456,
|
||||
"6cm" => 5760,
|
||||
"3cm" => 10_000,
|
||||
"1.25cm" => 24_000,
|
||||
"6mm" => 47_000,
|
||||
"4mm" => 75_000,
|
||||
"2.5mm" => 122_000,
|
||||
"2mm" => 134_000,
|
||||
"1mm" => 241_000
|
||||
}
|
||||
|
||||
# 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]
|
||||
|
||||
@doc """
|
||||
Parse ADIF string, validate, and deduplicate. Returns the same preview
|
||||
shape as `CsvImport.preview/2`.
|
||||
"""
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
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 -> string
|
||||
end
|
||||
|
||||
# Split on <EOR> to get individual records
|
||||
body
|
||||
|> String.split(~r/<EOR>/i)
|
||||
|> Enum.map(&parse_fields/1)
|
||||
|> Enum.reject(&(&1 == %{}))
|
||||
end
|
||||
|
||||
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
|
||||
name = record_string |> String.slice(name_start, name_len) |> String.upcase()
|
||||
size = record_string |> String.slice(size_start, size_len) |> String.to_integer()
|
||||
|
||||
# Value starts right after the closing >
|
||||
# Find the > after the tag
|
||||
tag_text = String.slice(record_string, tag_start, 999)
|
||||
value_offset = tag_start + byte_size(String.split(tag_text, ">", parts: 2) |> List.first()) + 1
|
||||
value = String.slice(record_string, value_offset, size)
|
||||
|
||||
Map.put(acc, name, value)
|
||||
end)
|
||||
end
|
||||
|
||||
# -- row building ----------------------------------------------------------
|
||||
|
||||
defp build_row({fields, row_num}, submitter_email) do
|
||||
station1 = fields["STATION_CALLSIGN"] || fields["OPERATOR"]
|
||||
station2 = fields["CALL"]
|
||||
grid1 = fields["MY_GRIDSQUARE"]
|
||||
grid2 = fields["GRIDSQUARE"]
|
||||
mode = fields["MODE"]
|
||||
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"] ->
|
||||
freq_mhz = parse_freq_mhz(freq)
|
||||
nearest_band(freq_mhz)
|
||||
|
||||
band_name = fields["BAND"] ->
|
||||
band_name = String.downcase(band_name)
|
||||
mhz = @band_name_to_mhz[band_name]
|
||||
if mhz, do: to_string(mhz)
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_freq_mhz(freq_str) do
|
||||
{freq, _} = Float.parse(String.trim(freq_str))
|
||||
# ADIF FREQ is always in MHz (e.g., 10368.000 for 10 GHz band)
|
||||
freq
|
||||
end
|
||||
|
||||
defp nearest_band(freq_mhz) when freq_mhz >= 900 do
|
||||
@allowed_bands
|
||||
|> Enum.min_by(&abs(&1 - freq_mhz))
|
||||
|> to_string()
|
||||
end
|
||||
|
||||
defp nearest_band(_freq_mhz), do: nil
|
||||
|
||||
# -- 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
|
||||
|
|
@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Radio.AdifImport
|
||||
alias Microwaveprop.Radio.CsvImport
|
||||
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
||||
|
||||
|
|
@ -15,7 +16,10 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
socket = allow_upload(socket, :csv, accept: ~w(.csv), max_entries: 1, max_file_size: 10_000_000)
|
||||
socket =
|
||||
socket
|
||||
|> allow_upload(:csv, accept: ~w(.csv), max_entries: 1, max_file_size: 10_000_000)
|
||||
|> allow_upload(:adif, accept: :any, max_entries: 1, max_file_size: 10_000_000)
|
||||
|
||||
changeset = Radio.change_contact(%Contact{})
|
||||
|
||||
|
|
@ -60,6 +64,10 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate_adif", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Minimum milliseconds between submissions per LiveView session.
|
||||
# For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare).
|
||||
@submission_cooldown_ms 30_000
|
||||
|
|
@ -124,6 +132,35 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
{:noreply, cancel_upload(socket, :csv, ref)}
|
||||
end
|
||||
|
||||
def handle_event("upload_adif", params, socket) do
|
||||
email =
|
||||
case current_user(socket.assigns) do
|
||||
%{email: user_email} -> user_email
|
||||
_ -> params |> Map.get("submitter_email", "") |> String.trim()
|
||||
end
|
||||
|
||||
if email == "" do
|
||||
{:noreply, put_flash(socket, :error, "Email is required for ADIF upload")}
|
||||
else
|
||||
uploaded_contents =
|
||||
consume_uploaded_entries(socket, :adif, fn %{path: path}, _entry ->
|
||||
{:ok, File.read!(path)}
|
||||
end)
|
||||
|
||||
case uploaded_contents do
|
||||
[content] ->
|
||||
handle_adif_preview(content, email, socket)
|
||||
|
||||
[] ->
|
||||
{:noreply, put_flash(socket, :error, "Please select an ADIF file")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel_adif_upload", %{"ref" => ref}, socket) do
|
||||
{:noreply, cancel_upload(socket, :adif, ref)}
|
||||
end
|
||||
|
||||
def handle_event("confirm_csv", _params, socket) do
|
||||
case socket.assigns.csv_preview do
|
||||
%{valid: valid_rows} when valid_rows != [] ->
|
||||
|
|
@ -147,6 +184,16 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
{:noreply, assign(socket, csv_preview: nil, csv_result: nil)}
|
||||
end
|
||||
|
||||
defp handle_adif_preview(content, email, socket) do
|
||||
case AdifImport.preview(content, email) do
|
||||
{:ok, preview} ->
|
||||
{:noreply, assign(socket, csv_preview: preview, csv_result: nil, csv_email: email)}
|
||||
|
||||
{:error, :no_records} ->
|
||||
{:noreply, put_flash(socket, :error, "ADIF file contains no records")}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_csv_preview(content, email, socket) do
|
||||
case CsvImport.preview(content, email) do
|
||||
{:ok, preview} ->
|
||||
|
|
@ -181,7 +228,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
|
||||
defp error_to_string(:too_large), do: "File is too large"
|
||||
defp error_to_string(:too_many_files), do: "Only one file allowed"
|
||||
defp error_to_string(:not_accepted), do: "Only .csv files are accepted"
|
||||
defp error_to_string(:not_accepted), do: "File type not accepted"
|
||||
defp error_to_string(other), do: to_string(other)
|
||||
|
||||
@impl true
|
||||
|
|
@ -236,24 +283,44 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
>
|
||||
Upload CSV
|
||||
</button>
|
||||
<button
|
||||
class={[
|
||||
"px-4 py-2 text-sm font-medium rounded-md transition-colors",
|
||||
if(@active_tab == :adif, do: "bg-primary text-primary-content", else: "hover:bg-base-300")
|
||||
]}
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab="adif"
|
||||
>
|
||||
Upload ADIF
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @active_tab == :single do %>
|
||||
<.single_contact_form
|
||||
form={@form}
|
||||
band_options={@band_options}
|
||||
mode_options={@mode_options}
|
||||
current_user={current_user(assigns)}
|
||||
/>
|
||||
<% else %>
|
||||
<%= cond do %>
|
||||
<% @csv_result -> %>
|
||||
<.csv_results result={@csv_result} />
|
||||
<% @csv_preview -> %>
|
||||
<.csv_preview preview={@csv_preview} />
|
||||
<% true -> %>
|
||||
<.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
|
||||
<% end %>
|
||||
<%= case @active_tab do %>
|
||||
<% :single -> %>
|
||||
<.single_contact_form
|
||||
form={@form}
|
||||
band_options={@band_options}
|
||||
mode_options={@mode_options}
|
||||
current_user={current_user(assigns)}
|
||||
/>
|
||||
<% :csv -> %>
|
||||
<%= cond do %>
|
||||
<% @csv_result -> %>
|
||||
<.csv_results result={@csv_result} />
|
||||
<% @csv_preview -> %>
|
||||
<.csv_preview preview={@csv_preview} />
|
||||
<% true -> %>
|
||||
<.csv_upload_form uploads={@uploads} current_user={current_user(assigns)} />
|
||||
<% end %>
|
||||
<% :adif -> %>
|
||||
<%= cond do %>
|
||||
<% @csv_result -> %>
|
||||
<.csv_results result={@csv_result} />
|
||||
<% @csv_preview -> %>
|
||||
<.csv_preview preview={@csv_preview} />
|
||||
<% true -> %>
|
||||
<.adif_upload_form uploads={@uploads} current_user={current_user(assigns)} />
|
||||
<% end %>
|
||||
<% end %>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -426,6 +493,91 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
"""
|
||||
end
|
||||
|
||||
defp adif_upload_form(assigns) do
|
||||
~H"""
|
||||
<div class="space-y-6">
|
||||
<div class="bg-base-200 rounded-box p-4 text-sm">
|
||||
<p class="mb-2">
|
||||
Upload an ADIF (.adi / .adif) file exported from your logging program.
|
||||
</p>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1 text-base-content/60">
|
||||
<li>
|
||||
Required fields: <code>CALL</code>, <code>STATION_CALLSIGN</code> (or <code>OPERATOR</code>),
|
||||
<code>GRIDSQUARE</code>, <code>MY_GRIDSQUARE</code>, <code>QSO_DATE</code>, <code>TIME_ON</code>,
|
||||
and <code>FREQ</code> or <code>BAND</code>
|
||||
</li>
|
||||
<li>Only microwave contacts (902 MHz and up) will be imported</li>
|
||||
<li>Sub-microwave contacts are silently skipped</li>
|
||||
<li>Frequencies are fuzzy-matched to the nearest microwave band</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form id="adif-upload-form" phx-submit="upload_adif" phx-change="validate_adif" class="space-y-4">
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span class="label mb-1">ADIF File</span>
|
||||
<.live_file_input upload={@uploads.adif} class="file-input file-input-bordered w-full" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div :for={entry <- @uploads.adif.entries} class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs tabular-nums">
|
||||
<span class="truncate pr-2">{entry.client_name}</span>
|
||||
<span class="flex items-center gap-2 shrink-0">
|
||||
<span>{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
phx-click="cancel_adif_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
aria-label="Cancel upload"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-4 h-4" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<progress
|
||||
class="progress progress-primary w-full"
|
||||
value={entry.progress}
|
||||
max="100"
|
||||
>
|
||||
</progress>
|
||||
<div :for={err <- upload_errors(@uploads.adif, entry)} class="text-xs text-error">
|
||||
{error_to_string(err)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :for={err <- upload_errors(@uploads.adif)} class="text-xs text-error">
|
||||
{error_to_string(err)}
|
||||
</div>
|
||||
|
||||
<%= if @current_user do %>
|
||||
<input type="hidden" name="submitter_email" value={@current_user.email} />
|
||||
<% else %>
|
||||
<div class="fieldset mb-2">
|
||||
<label>
|
||||
<span class="label mb-1">Your Email</span>
|
||||
<input
|
||||
type="email"
|
||||
name="submitter_email"
|
||||
class="w-full input"
|
||||
placeholder="you@example.com"
|
||||
value=""
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
|
||||
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp csv_preview(assigns) do
|
||||
assigns =
|
||||
assign(assigns,
|
||||
|
|
|
|||
187
test/microwaveprop/radio/adif_import_test.exs
Normal file
187
test/microwaveprop/radio/adif_import_test.exs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
defmodule Microwaveprop.Radio.AdifImportTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.Radio.AdifImport
|
||||
alias Microwaveprop.Terrain.ElevationClient
|
||||
alias Microwaveprop.Weather.HrrrClient
|
||||
alias Microwaveprop.Weather.IemClient
|
||||
|
||||
setup do
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
case conn.request_path do
|
||||
"/cgi-bin/request/asos.py" -> Req.Test.text(conn, "#DEBUG,\nstation,valid,tmpf\n")
|
||||
_ -> Req.Test.json(conn, %{"profiles" => []})
|
||||
end
|
||||
end)
|
||||
|
||||
Req.Test.stub(HrrrClient, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end)
|
||||
|
||||
Req.Test.stub(ElevationClient, fn conn ->
|
||||
params = Plug.Conn.fetch_query_params(conn).query_params
|
||||
lat_count = params["latitude"] |> String.split(",") |> length()
|
||||
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@submitter "test@example.com"
|
||||
|
||||
@valid_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>
|
||||
"""
|
||||
|
||||
describe "preview/2" do
|
||||
test "parses a valid ADIF record" do
|
||||
assert {:ok, preview} = AdifImport.preview(@valid_adif, @submitter)
|
||||
assert length(preview.valid) == 1
|
||||
assert preview.invalid == []
|
||||
assert preview.duplicates == []
|
||||
|
||||
[row] = preview.valid
|
||||
assert row.attrs["station1"] == "W5XD"
|
||||
assert row.attrs["station2"] == "K5TR"
|
||||
assert row.attrs["grid1"] == "EM12"
|
||||
assert row.attrs["grid2"] == "EM00"
|
||||
assert row.attrs["band"] == "10000"
|
||||
assert row.attrs["mode"] == "CW"
|
||||
end
|
||||
|
||||
test "parses ADIF with BAND field instead of FREQ" do
|
||||
adif = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<BAND:4>23cm<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert [row] = preview.valid
|
||||
assert row.attrs["band"] == "1296"
|
||||
end
|
||||
|
||||
test "maps ADIF frequency to correct band" do
|
||||
for {freq, expected_band} <- [
|
||||
{"1296.200", "1296"},
|
||||
{"2304.000", "2304"},
|
||||
{"5760.100", "5760"},
|
||||
{"10368.000", "10000"},
|
||||
{"24192.000", "24000"},
|
||||
{"47088.000", "47000"},
|
||||
{"76032.000", "75000"}
|
||||
] do
|
||||
adif = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:#{byte_size(freq)}>#{freq}<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert [row] = preview.valid
|
||||
assert row.attrs["band"] == expected_band, "FREQ #{freq} should map to band #{expected_band}, got #{row.attrs["band"]}"
|
||||
end
|
||||
end
|
||||
|
||||
test "handles multiple records" 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>
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:5>N5DUP<GRIDSQUARE:4>EM13<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:3>SSB<QSO_DATE:8>20260328<TIME_ON:6>190000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert length(preview.valid) == 2
|
||||
end
|
||||
|
||||
test "skips ADIF header before <EOH>" do
|
||||
adif = """
|
||||
ADIF Export from Logger32
|
||||
<ADIF_VER:5>3.1.4
|
||||
<PROGRAMID:8>Logger32
|
||||
<EOH>
|
||||
<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>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert length(preview.valid) == 1
|
||||
end
|
||||
|
||||
test "returns error for empty ADIF" do
|
||||
assert {:error, :no_records} = AdifImport.preview("", @submitter)
|
||||
end
|
||||
|
||||
test "returns error for header-only ADIF" do
|
||||
adif = """
|
||||
ADIF Export
|
||||
<ADIF_VER:5>3.1.4
|
||||
<EOH>
|
||||
"""
|
||||
|
||||
assert {:error, :no_records} = AdifImport.preview(adif, @submitter)
|
||||
end
|
||||
|
||||
test "marks record invalid when missing required fields" do
|
||||
adif = """
|
||||
<CALL:4>K5TR<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert preview.valid == []
|
||||
assert length(preview.invalid) == 1
|
||||
end
|
||||
|
||||
test "skips non-microwave contacts" do
|
||||
adif = """
|
||||
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:7>144.200<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert preview.valid == []
|
||||
assert length(preview.invalid) == 1
|
||||
end
|
||||
|
||||
test "deduplicates against existing DB contacts" do
|
||||
# Insert one via CSV import path
|
||||
{:ok, _} =
|
||||
Microwaveprop.Radio.create_contact(%{
|
||||
station1: "W5XD",
|
||||
station2: "K5TR",
|
||||
grid1: "EM12",
|
||||
grid2: "EM00",
|
||||
band: "10000",
|
||||
mode: "CW",
|
||||
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||
submitter_email: @submitter
|
||||
})
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(@valid_adif, @submitter)
|
||||
assert preview.valid == []
|
||||
assert length(preview.duplicates) == 1
|
||||
end
|
||||
|
||||
test "is case-insensitive for field names" 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>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert length(preview.valid) == 1
|
||||
end
|
||||
|
||||
test "handles TIME_ON with 4 digits (HHMM)" 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:4>1800<EOR>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert [row] = preview.valid
|
||||
assert row.timestamp == ~U[2026-03-28 18:00:00Z]
|
||||
end
|
||||
|
||||
test "uses OPERATOR field when STATION_CALLSIGN is missing" do
|
||||
adif = """
|
||||
<OPERATOR: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>
|
||||
"""
|
||||
|
||||
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
||||
assert [row] = preview.valid
|
||||
assert row.attrs["station1"] == "W5XD"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue