prop/lib/microwaveprop/radio/maidenhead_changeset_helpers.ex
Graham McIntire b4b8d4ec47
Some checks failed
Build base image / Build and push base image (push) Successful in 12s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 14m7s
simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign
  normalization, lat/lon validation, grid/latlon derivation across 6 schemas
- Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue
  for Oban workers, UUID casting to replace CastError rescues
- Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions),
  subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2
- Extract Propagation.ScoreStore (528 lines): separate file I/O and cache
  management from scoring logic, 13 defdelegate passthroughs
- Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into
  3 function component modules (csv_upload, adif_upload, preview)
- Update 16 LiveViews to use shared helpers
2026-08-06 18:06:50 -05:00

156 lines
5 KiB
Elixir

defmodule Microwaveprop.Radio.MaidenheadChangesetHelpers do
@moduledoc """
Shared changeset helpers for Maidenhead grid squares, callsigns, and
lat/lon coordinate validation. Used across multiple domain schemas
(Beacon, User, FixedStation, Contact, Location, BeaconMonitor).
"""
import Ecto.Changeset
alias Microwaveprop.Radio.Maidenhead
@doc """
Trims whitespace and uppercases a callsign field if a change is present.
Returns the changeset unchanged when the field has no change or the
change is `nil`.
"""
@spec normalize_callsign(Ecto.Changeset.t(), atom()) :: Ecto.Changeset.t()
def normalize_callsign(changeset, field) do
update_change(changeset, field, fn
nil -> nil
val when is_binary(val) -> val |> String.trim() |> String.upcase()
val -> val
end)
end
@doc """
Validates a Maidenhead grid field on the changeset.
## Options
* `:blank_error` — when `true` (default), a `nil` or empty-string value
adds a "can't be blank" error. When `false`, blank values pass through
silently.
* `:normalizer` — a function of one argument called to normalize the
grid when it passes validation. Defaults to `normalize_grid/1`.
"""
@spec validate_grid(Ecto.Changeset.t(), atom(), keyword()) :: Ecto.Changeset.t()
def validate_grid(changeset, field, opts \\ []) do
blank_error = Keyword.get(opts, :blank_error, true)
normalizer = Keyword.get(opts, :normalizer, &normalize_grid/1)
case get_field(changeset, field) do
nil ->
if blank_error do
add_error(changeset, field, "can't be blank")
else
changeset
end
"" ->
if blank_error do
add_error(changeset, field, "can't be blank")
else
changeset
end
value ->
if Maidenhead.valid?(value) do
update_change(changeset, field, normalizer)
else
add_error(changeset, field, "is not a valid Maidenhead grid")
end
end
end
@doc """
Normalizes a Maidenhead grid string to standard form.
* `nil` → `nil`
* Trims leading and trailing whitespace.
* For grids of 6 or more characters: uppercases the first 4 characters
(field + square) and downcases the rest (subsquares and beyond).
* For grids of fewer than 6 characters: uppercases the entire string.
"""
@spec normalize_grid(nil | String.t()) :: nil | String.t()
def normalize_grid(nil), do: nil
def normalize_grid(grid) when is_binary(grid) do
trimmed = String.trim(grid)
if String.length(trimmed) >= 6 do
String.upcase(String.slice(trimmed, 0, 4)) <> String.downcase(String.slice(trimmed, 4, String.length(trimmed) - 4))
else
String.upcase(trimmed)
end
end
@doc """
Derives lat/lon coordinates from a Maidenhead grid field when lat/lon
are not already set. Only runs when the changeset is valid.
Uses the centre point of the grid square, rounded to 6 decimal places.
"""
@spec derive_latlon(
Ecto.Changeset.t(),
atom(),
atom(),
atom()
) :: Ecto.Changeset.t()
def derive_latlon(changeset, grid_field \\ :grid, lat_field \\ :lat, lon_field \\ :lon) do
grid = get_field(changeset, grid_field)
lat = get_field(changeset, lat_field)
lon = get_field(changeset, lon_field)
if changeset.valid? and is_binary(grid) and grid != "" and
(not is_number(lat) or not is_number(lon)) do
case Maidenhead.to_latlon(grid) do
{:ok, {derived_lat, derived_lon}} ->
changeset
|> put_change(lat_field, Float.round(derived_lat * 1.0, 6))
|> put_change(lon_field, Float.round(derived_lon * 1.0, 6))
:error ->
changeset
end
else
changeset
end
end
@doc """
Derives a Maidenhead grid from lat/lon coordinates when the grid field
is blank and lat/lon are numbers.
The `precision` must be an even number between 4 and 10 (default 6).
"""
@spec derive_grid(
Ecto.Changeset.t(),
atom(),
atom(),
atom(),
pos_integer()
) :: Ecto.Changeset.t()
def derive_grid(changeset, grid_field \\ :grid, lat_field \\ :lat, lon_field \\ :lon, precision \\ 6) do
grid = get_field(changeset, grid_field)
lat = get_field(changeset, lat_field)
lon = get_field(changeset, lon_field)
if grid in [nil, ""] and is_number(lat) and is_number(lon) do
put_change(changeset, grid_field, Maidenhead.from_latlon(lat * 1.0, lon * 1.0, precision))
else
changeset
end
end
@doc """
Validates that latitude is between -90 and 90 and longitude is between
-180 and 180.
"""
@spec validate_latlon(Ecto.Changeset.t(), atom(), atom()) :: Ecto.Changeset.t()
def validate_latlon(changeset, lat_field \\ :lat, lon_field \\ :lon) do
changeset
|> validate_number(lat_field, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|> validate_number(lon_field, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
end
end