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.
479 lines
16 KiB
Elixir
479 lines
16 KiB
Elixir
defmodule Microwaveprop.Radio.CsvImport do
|
|
@moduledoc """
|
|
Parse a CSV of contacts into a preview (validated + de-duplicated) and then
|
|
commit those rows into the database.
|
|
|
|
Upload flow:
|
|
|
|
{:ok, preview} = CsvImport.preview(csv_string, submitter_email)
|
|
# show preview.valid / preview.invalid / preview.duplicates to the user
|
|
{:ok, result} = CsvImport.commit(preview.valid)
|
|
|
|
Two rows count as duplicates of each other if they share:
|
|
|
|
* the same pair of callsigns (direction-agnostic),
|
|
* the same grids for those callsigns,
|
|
* the same band, and
|
|
* timestamps within 60 minutes of each other.
|
|
|
|
Dedup runs both against existing DB contacts and against earlier rows in
|
|
the same CSV (first row wins, subsequent matches are flagged).
|
|
"""
|
|
|
|
import Ecto.Query, only: [from: 2]
|
|
|
|
alias Microwaveprop.Radio
|
|
alias Microwaveprop.Radio.BandResolver
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
|
|
|
@dedup_window_seconds 3600
|
|
|
|
@columns_with_mode ~w(station1 station2 grid1 grid2 band mode qso_timestamp)
|
|
@columns_without_mode ~w(station1 station2 grid1 grid2 band qso_timestamp)
|
|
|
|
@doc """
|
|
Parse-and-insert in one shot. Kept for backward compatibility — does NOT
|
|
perform duplicate detection. New code should use `preview/2` + `commit/1`.
|
|
"""
|
|
@type preview_result :: %{
|
|
valid: [map()],
|
|
invalid: [map()],
|
|
duplicates: [map()],
|
|
total_rows: non_neg_integer(),
|
|
submitter_email: String.t()
|
|
}
|
|
|
|
@type import_result :: %{
|
|
imported: [Contact.t()],
|
|
errors: [{pos_integer(), [String.t()]}]
|
|
}
|
|
|
|
@type commit_result :: %{
|
|
imported: [Contact.t()],
|
|
errors: [{pos_integer(), [String.t()]}]
|
|
}
|
|
|
|
@spec import(String.t(), String.t()) ::
|
|
{:ok, import_result()} | {:error, :empty_csv} | {:error, :no_data_rows}
|
|
def import(csv_string, submitter_email) do
|
|
with {:ok, rows} <- parse_csv(csv_string, submitter_email) do
|
|
{valid, invalid} = split_parsed(rows)
|
|
{:ok, commit_result} = commit(valid)
|
|
invalid_errors = Enum.map(invalid, fn %{row_num: n, messages: m} -> {n, m} end)
|
|
|
|
{:ok,
|
|
%{
|
|
imported: commit_result.imported,
|
|
errors: invalid_errors ++ commit_result.errors
|
|
}}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Parses the CSV, validates each row with the submission changeset, and
|
|
classifies every row as valid / invalid / duplicate without inserting.
|
|
|
|
Returns:
|
|
|
|
* `{:ok, preview}` where `preview` is a map with keys
|
|
`:valid`, `:invalid`, `:duplicates`, `:total_rows`, `:submitter_email`.
|
|
* `{:error, :empty_csv}` if there is nothing to process.
|
|
* `{:error, :no_data_rows}` if only a header is present.
|
|
|
|
Valid rows are maps of the form `%{row_num, attrs, timestamp}`. Duplicate
|
|
rows add a `:reason` field indicating whether the conflict was found in
|
|
the database (`:existing_contact`) or earlier in the uploaded file
|
|
(`:earlier_in_upload`).
|
|
"""
|
|
@spec preview(String.t(), String.t()) ::
|
|
{:ok, preview_result()} | {:error, :empty_csv} | {:error, :no_data_rows}
|
|
def preview(csv_string, submitter_email) do
|
|
with {:ok, rows} <- parse_csv(csv_string, submitter_email) do
|
|
{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(rows),
|
|
submitter_email: submitter_email
|
|
}}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Inserts the rows returned by `preview/2` in `:valid`. Each is passed to
|
|
`Radio.create_contact/1` and its enrichment is enqueued on success.
|
|
Returns `{:ok, %{imported: [...], errors: [{row_num, messages}, ...]}}`.
|
|
"""
|
|
@spec commit([map()]) :: {:ok, commit_result()}
|
|
def commit(valid_rows) when is_list(valid_rows) do
|
|
result =
|
|
Enum.reduce(valid_rows, %{imported: [], errors: []}, fn row, acc ->
|
|
case Radio.create_contact(row.attrs) do
|
|
{:ok, contact} ->
|
|
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
|
%{acc | imported: [contact | acc.imported]}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
%{acc | errors: [{row.row_num, changeset_error_strings(changeset)} | acc.errors]}
|
|
end
|
|
end)
|
|
|
|
{:ok, %{imported: Enum.reverse(result.imported), errors: Enum.reverse(result.errors)}}
|
|
end
|
|
|
|
# -- parsing ---------------------------------------------------------------
|
|
|
|
defp parse_csv(csv_string, submitter_email) do
|
|
numbered_lines =
|
|
csv_string
|
|
|> String.replace("\r\n", "\n")
|
|
|> String.split("\n")
|
|
|> Enum.with_index(1)
|
|
|
|
non_blank = Enum.reject(numbered_lines, fn {line, _num} -> blank?(line) end)
|
|
|
|
case non_blank do
|
|
[] ->
|
|
{:error, :empty_csv}
|
|
|
|
[{_header, _}] ->
|
|
{:error, :no_data_rows}
|
|
|
|
[{_header, _} | data_lines] ->
|
|
rows = Enum.map(data_lines, fn {line, row_num} -> parse_row(line, row_num, submitter_email) end)
|
|
{:ok, rows}
|
|
end
|
|
end
|
|
|
|
defp blank?(line), do: String.trim(line) == ""
|
|
|
|
defp parse_row(line, row_num, submitter_email) do
|
|
fields = parse_csv_fields(line)
|
|
|
|
case columns_for(length(fields)) do
|
|
nil ->
|
|
{:invalid,
|
|
%{
|
|
row_num: row_num,
|
|
messages: ["expected 6 columns (no mode) or 7 columns (with mode), got #{length(fields)}"]
|
|
}}
|
|
|
|
columns ->
|
|
attrs =
|
|
columns
|
|
|> Enum.zip(fields)
|
|
|> Map.new()
|
|
|> Map.put("submitter_email", submitter_email)
|
|
|
|
parse_row_with_timestamp(attrs, row_num)
|
|
end
|
|
end
|
|
|
|
defp columns_for(7), do: @columns_with_mode
|
|
defp columns_for(6), do: @columns_without_mode
|
|
defp columns_for(_), do: nil
|
|
|
|
defp parse_row_with_timestamp(attrs, row_num) do
|
|
case normalize_timestamp(attrs["qso_timestamp"]) do
|
|
{:ok, iso_timestamp} ->
|
|
validate_parsed_row(attrs, row_num, iso_timestamp)
|
|
|
|
{:error, msg} ->
|
|
{:invalid, %{row_num: row_num, messages: [msg]}}
|
|
end
|
|
end
|
|
|
|
defp validate_parsed_row(attrs, row_num, iso_timestamp) do
|
|
attrs =
|
|
attrs
|
|
|> Map.put("qso_timestamp", iso_timestamp)
|
|
|> normalize_band_attr()
|
|
|
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
|
|
|
if changeset.valid? do
|
|
{:ok, datetime} = parse_iso_datetime(iso_timestamp)
|
|
{:parsed, %{row_num: row_num, attrs: attrs, timestamp: datetime}}
|
|
else
|
|
{:invalid, %{row_num: row_num, messages: changeset_error_strings(changeset)}}
|
|
end
|
|
end
|
|
|
|
# CSV users may type ADIF wavelength labels ("33cm"), bare frequencies
|
|
# ("903.100"), or canonical band numbers ("902"). Run the supplied value
|
|
# through BandResolver so any of those forms end up as an integer MHz
|
|
# string that Contact.submission_changeset's validate_inclusion accepts.
|
|
# If the input is unresolvable we leave it alone so the user gets a
|
|
# specific "band is invalid" changeset error instead of a silent drop.
|
|
defp normalize_band_attr(attrs) do
|
|
case attrs["band"] do
|
|
nil ->
|
|
attrs
|
|
|
|
raw ->
|
|
case BandResolver.resolve_as_string(raw) do
|
|
nil -> attrs
|
|
canonical -> Map.put(attrs, "band", canonical)
|
|
end
|
|
end
|
|
end
|
|
|
|
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
|
|
|
|
# -- dedup -----------------------------------------------------------------
|
|
|
|
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(&to_decimal/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
|
|
# in_batch_map accumulates keys from rows already accepted in this CSV so
|
|
# we can distinguish DB duplicates from earlier-row duplicates when we
|
|
# report them.
|
|
{accepted, _in_batch, duplicates} =
|
|
Enum.reduce(rows, {[], %{}, []}, fn row, {accepted, in_batch, dups} ->
|
|
key = dedup_key_from_attrs(row.attrs)
|
|
|
|
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_from_attrs(attrs) do
|
|
dedup_key(attrs["station1"], attrs["grid1"], attrs["station2"], attrs["grid2"], attrs["band"])
|
|
end
|
|
|
|
defp dedup_key(s1, g1, s2, g2, band) do
|
|
a = normalize_callsite(s1, g1)
|
|
b = normalize_callsite(s2, g2)
|
|
[c1, c2] = Enum.sort([a, b])
|
|
{c1, c2, normalize_band(band)}
|
|
end
|
|
|
|
defp normalize_callsite(call, grid) do
|
|
{upcase_trim(call), upcase_trim(grid)}
|
|
end
|
|
|
|
defp upcase_trim(nil), do: ""
|
|
defp upcase_trim(value), do: value |> 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 to_decimal(%Decimal{} = d), do: d
|
|
defp to_decimal(v) when is_integer(v), do: Decimal.new(v)
|
|
defp to_decimal(v) when is_binary(v), do: v |> String.trim() |> Decimal.new()
|
|
|
|
defp parse_iso_datetime(iso) do
|
|
case DateTime.from_iso8601(iso) do
|
|
{:ok, dt, _} -> {:ok, dt}
|
|
_ -> :error
|
|
end
|
|
end
|
|
|
|
# -- timestamps ------------------------------------------------------------
|
|
|
|
# Regex patterns for timestamp parsing, ordered most-specific first.
|
|
# Accepts many hand-entered formats. All times assumed UTC.
|
|
# Each returns {year, month, day, hour, minute, second} or nil.
|
|
defp timestamp_parsers do
|
|
[
|
|
# ISO-ish: 2024-06-15T14:30:00Z, 2024-06-15 14:30, 2024/06/15 14:30:00, etc.
|
|
# Accepts T or space separator, optional seconds, optional timezone suffix
|
|
{~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$"i,
|
|
fn
|
|
[_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
|
|
[_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"}
|
|
end},
|
|
# Date only: 2024-06-15, 2024/06/15
|
|
{~r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$", fn [_, y, m, d] -> {y, m, d, "00", "00", "00"} end},
|
|
# US with AM/PM: 6/15/2024 2:30 PM, 6-15-2024 2:30PM, 06.15.2024 02:30 am
|
|
{~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)$"i,
|
|
fn
|
|
[_, m, d, y, h, mi, s, ampm] when byte_size(s) > 0 ->
|
|
{y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, s}
|
|
|
|
[_, m, d, y, h, mi, _, ampm] ->
|
|
{y, m, d, to_string(parse_12h(String.to_integer(h), String.upcase(ampm))), mi, "00"}
|
|
end},
|
|
# US 24h: 6/15/2024 14:30, 06-15-2024 14:30:00, 6.15.2024 14:30
|
|
{~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$",
|
|
fn
|
|
[_, m, d, y, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
|
|
[_, m, d, y, h, mi | _] -> {y, m, d, h, mi, "00"}
|
|
end},
|
|
# US date only: 6/15/2024, 06-15-2024
|
|
{~r"^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$", fn [_, m, d, y] -> {y, m, d, "00", "00", "00"} end},
|
|
# Compact: 20240615T143000, 20240615 1430, 20240615T1430
|
|
{~r"^(\d{4})(\d{2})(\d{2})[T\s]?(\d{2})(\d{2})(\d{2})?$",
|
|
fn
|
|
[_, y, m, d, h, mi, s] when byte_size(s) > 0 -> {y, m, d, h, mi, s}
|
|
[_, y, m, d, h, mi | _] -> {y, m, d, h, mi, "00"}
|
|
end}
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Parses various date/time formats into ISO 8601 UTC strings.
|
|
All times are assumed UTC.
|
|
"""
|
|
@spec normalize_timestamp(String.t()) :: {:ok, String.t()} | {:error, String.t()}
|
|
def normalize_timestamp(raw) do
|
|
trimmed = String.trim(raw)
|
|
|
|
# Try ISO 8601 with Elixir's built-in parser first
|
|
case DateTime.from_iso8601(trimmed) do
|
|
{:ok, dt, _} ->
|
|
{:ok, DateTime.to_iso8601(dt)}
|
|
|
|
_ ->
|
|
try_regex_parsers(trimmed)
|
|
end
|
|
end
|
|
|
|
defp try_regex_parsers(trimmed) do
|
|
timestamp_parsers()
|
|
|> Enum.find_value(fn {regex, extractor} ->
|
|
case Regex.run(regex, trimmed) do
|
|
nil -> nil
|
|
match -> format_iso(extractor.(match))
|
|
end
|
|
end)
|
|
|> case do
|
|
{:ok, _} = ok -> ok
|
|
nil -> {:error, "qso_timestamp could not be parsed: #{trimmed}"}
|
|
end
|
|
end
|
|
|
|
defp parse_12h(12, "AM"), do: 0
|
|
defp parse_12h(12, "PM"), do: 12
|
|
defp parse_12h(h, "PM"), do: h + 12
|
|
defp parse_12h(h, "AM"), do: h
|
|
|
|
defp format_iso({y, m, d, h, mi, s}) do
|
|
y = String.pad_leading(to_string(y), 4, "0")
|
|
m = String.pad_leading(to_string(m), 2, "0")
|
|
d = String.pad_leading(to_string(d), 2, "0")
|
|
h = String.pad_leading(to_string(h), 2, "0")
|
|
mi = String.pad_leading(to_string(mi), 2, "0")
|
|
s = String.pad_leading(to_string(s), 2, "0")
|
|
{:ok, "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"}
|
|
end
|
|
|
|
# -- csv field parser ------------------------------------------------------
|
|
|
|
# RFC 4180 CSV field parser. Handles quoted fields with commas and escaped quotes.
|
|
defp parse_csv_fields(line) do
|
|
line
|
|
|> String.trim()
|
|
|> do_parse_fields([], "")
|
|
|> Enum.reverse()
|
|
|> Enum.map(&String.trim/1)
|
|
end
|
|
|
|
defp do_parse_fields("", acc, current), do: [current | acc]
|
|
|
|
defp do_parse_fields(<<"\"", rest::binary>>, acc, "") do
|
|
parse_quoted_field(rest, acc, "")
|
|
end
|
|
|
|
defp do_parse_fields(<<",", rest::binary>>, acc, current) do
|
|
do_parse_fields(rest, [current | acc], "")
|
|
end
|
|
|
|
defp do_parse_fields(<<c, rest::binary>>, acc, current) do
|
|
do_parse_fields(rest, acc, current <> <<c>>)
|
|
end
|
|
|
|
defp parse_quoted_field(<<"\"\"", rest::binary>>, acc, current) do
|
|
parse_quoted_field(rest, acc, current <> "\"")
|
|
end
|
|
|
|
defp parse_quoted_field(<<"\"", rest::binary>>, acc, current) do
|
|
# End of quoted field — skip to next comma or end
|
|
case rest do
|
|
<<",", rest2::binary>> -> do_parse_fields(rest2, [current | acc], "")
|
|
"" -> [current | acc]
|
|
_ -> do_parse_fields(rest, [current | acc], "")
|
|
end
|
|
end
|
|
|
|
defp parse_quoted_field(<<c, rest::binary>>, acc, current) do
|
|
parse_quoted_field(rest, acc, current <> <<c>>)
|
|
end
|
|
|
|
defp parse_quoted_field("", acc, current), do: [current | acc]
|
|
|
|
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
|