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