Current section

Files

Jump to
address_formatting lib address_formatting.ex
Raw

lib/address_formatting.ex

defmodule AddressFormatting do
@moduledoc """
Documentation for `AddressFormatting`.
"""
alias AddressFormatting.Address
alias AddressFormatting.Constants
def render(variables) when is_map(variables) do
case get_template(variables) do
{nil, _, _} ->
variables
|> Map.delete("country_code")
|> Map.values()
|> Enum.join("\n")
|> Kernel.<>("\n")
data ->
render(data)
end
end
@spec render({String.t(), Address.t() | map, list}) :: String.t()
def render({template, address, postformat}) do
template
|> :bbmustache.compile(address, key_type: :atom)
|> String.trim()
|> run_postformat(postformat)
|> fix_duplicates()
|> Kernel.<>("\n")
end
def fix_duplicates(string) do
string
|> String.split("\n")
|> List.foldr([], fn current, acc ->
previous = List.first(acc)
if current == previous do
acc
else
[current | acc]
end
end)
|> Enum.map_join("\n", &deduplicate_csv/1)
end
# Remove consecutive duplicate segments within a comma-separated line.
# e.g. "Limón, Limón, Barrio Hospital" → "Limón, Barrio Hospital"
defp deduplicate_csv(line) do
line
|> String.split(", ")
|> List.foldr([], fn part, acc ->
if List.first(acc) == part, do: acc, else: [part | acc]
end)
|> Enum.join(", ")
end
def run_postformat(rendered, postformat) do
Enum.reduce(postformat, rendered, fn [from, to], rendered_acc ->
Regex.replace(from, rendered_acc, to)
end)
end
def run_replace(address, country_data) do
address_new =
country_data
|> Map.get("replace", [])
|> Enum.reduce(address, fn [field_from, to], acc ->
to_sub = String.replace(to, "$", "\\")
case String.split(field_from, "=") do
[from_pattern] ->
# No field specified — apply regex to every string field
regex = Regex.compile!(from_pattern)
acc
|> Map.from_struct()
|> Enum.reduce(acc, fn {k, v}, inner_acc ->
if is_binary(v) do
Map.put(inner_acc, k, Regex.replace(regex, v, to_sub))
else
inner_acc
end
end)
["country_code", from] ->
case Map.get(acc, :country_code) do
nil -> acc
^from -> Map.put(acc, :country_code, to)
_ -> acc
end
[field, from] ->
atom_field = String.to_existing_atom(field)
regex = Regex.compile!(from)
case Map.get(acc, atom_field) do
nil -> acc
value -> Map.put(acc, atom_field, Regex.replace(regex, value, to_sub))
end
end
end)
{:ok, address_new}
end
def get_template(variables) do
country_code = variables |> Map.get("country_code") |> upcase()
case Map.get(Constants.worldwide_data(), country_code) do
nil ->
{:ok, address} = convert_component_aliases(%Address{}, variables)
{
Constants.fallback_template(),
address,
default_postformat_regex()
}
country_data ->
with {:ok, address} <- convert_component_aliases(%Address{}, variables),
{:ok, address} <- check_country_case(address, variables, country_data),
{:ok, address} <- convert_constants(address),
{:ok, address} <- convert_country_numeric(address),
{:ok, address} <- run_replace(address, country_data),
{:ok, address} <- check_use_country(address, country_data),
{:ok, address} <- add_component(address, country_data),
{:ok, address} <- check_change_country(address, country_data),
{:ok, address} <- add_codes(address, :county_code),
{:ok, address} <- add_codes(address, :state_code) do
template_key = String.upcase(address.country_code)
final_country_data = Map.get(Constants.worldwide_data(), template_key) || country_data
{:ok, postformat} = get_postformat(final_country_data)
template =
if use_fallback?(address) do
Map.get(Constants.worldwide_fallback_templates(), template_key) ||
Constants.fallback_template()
else
Map.get(Constants.worldwide_templates(), template_key) ||
Constants.fallback_template()
end
{template, address, postformat}
end
end
end
defp use_fallback?(address) do
address.road == nil && address.postcode == nil
end
def default_postformat_regex do
[
[~r/\&#39\;/, "'"],
[~r/[\},\s]+$/, ""],
[~r/^[,\s]+/, ""],
# linestarting with dash due to a parameter missing
[~r/^- /, ""],
[~r/\n\h-/, "\n"],
# multiple commas to one
[~r/,\s*,/, ", "],
# one horiz whitespace behind comma
[~r/\h+,\h+/, ", "],
# multiple horiz whitespace to one
[~r/\h\h+/, " "],
# horiz whitespace, newline to newline
[~r/\h\n/, "\n"],
# newline comma to just newline
[~r/\n,/, "\n"],
# multiple commas to one
[~r/,,+/, ","],
# comma newline to just newline
[~r/,\n/, "\n"],
# newline plus space to newline
[~r/\n\h+/, "\n"],
[~r/\n\n+/, "\n"]
]
end
def get_postformat(country_data) do
postformat =
case Map.get(country_data, "postformat_replace") do
nil ->
default_postformat_regex()
postformat_regex_list ->
country_rules =
Enum.map(postformat_regex_list, fn [from, to] ->
[Regex.compile!(from), String.replace(to, "$", "\\")]
end)
# Run defaults first, then country rules, then a final whitespace cleanup
# pass so that country substitutions don't leave stray spaces on lines.
default_postformat_regex() ++ country_rules ++ [[~r/\h\n/, "\n"], [~r/\n\h+/, "\n"]]
end
{:ok, postformat}
end
def add_component(address, country_data) do
case Map.get(country_data, "add_component") do
nil ->
{:ok, address}
add_component ->
case String.split(add_component, "=") do
["country_code", value] ->
{:ok, %{address | country_code: value}}
[field, value] ->
{:ok, Map.put(address, to_existing_atom(field, :attention), value)}
end
end
end
def convert_component_aliases(address, variables) do
# Override: upstream components.yaml maps "district" → neighbourhood, but
# the reference implementation treats it as state_district.
components = Map.put(Constants.components(), :district, :state_district)
address =
Enum.reduce(variables, address, fn {key, v}, acc ->
atom_key = safe_to_atom(key)
cond do
atom_key == nil ->
%{acc | attention: v}
Map.has_key?(acc, atom_key) ->
# Field exists directly in struct — set it, then populate canonical if nil
new_value = adjust_value(v, atom_key)
acc = Map.put(acc, atom_key, new_value)
case Map.get(components, atom_key) do
nil ->
acc
^atom_key ->
# Already canonical
acc
canonical_key ->
if Map.get(acc, canonical_key) == nil do
Map.put(acc, canonical_key, adjust_value(v, canonical_key))
else
acc
end
end
true ->
# Not a direct struct field — resolve through alias map
case Map.get(components, atom_key) do
nil -> %{acc | attention: v}
canonical_key -> Map.put(acc, canonical_key, adjust_value(v, canonical_key))
end
end
end)
{:ok, address}
end
def convert_constants(address) do
address =
case Map.get(address, :country_code) do
"UK" ->
Map.put(address, :country_code, "GB")
"NL" ->
state = Map.get(address, :state)
cond do
state == nil ->
address
state == "Curaçao" ->
%{address | country: "Curaçao", country_code: "CW"}
String.downcase(state) =~ "sint maarten" ->
%{address | country: "Sint Maarten", country_code: "SX"}
String.downcase(state) =~ "aruba" ->
%{address | country: "Aruba", country_code: "AW"}
true ->
address
end
_other ->
address
end
{:ok, address}
end
def convert_country_numeric(address) do
address =
if is_integer?(address.country) do
%{address | country: address.state, state: nil}
else
address
end
{:ok, address}
end
def add_codes(address, :state_code) do
address =
if address.state_code == nil do
country_code = address.country_code
case lookup_code(Constants.get_codes_dict("state"), country_code, address.state) do
nil -> address
code -> %{address | state_code: code}
end
else
address
end
{:ok, address}
end
def add_codes(address, :county_code) do
address =
if address.county_code == nil do
country_code = address.country_code
case lookup_code(Constants.get_codes_dict("county"), country_code, address.county) do
nil -> address
code -> %{address | county_code: code}
end
else
address
end
{:ok, address}
end
defp lookup_code(_codes_map, nil, _name), do: nil
defp lookup_code(_codes_map, _country_code, nil), do: nil
defp lookup_code(codes_map, country_code, name) do
country_codes = Map.get(codes_map, country_code, %{})
name_lower = String.downcase(name)
Enum.find_value(country_codes, fn
{k, v} when is_binary(k) ->
if String.downcase(k) == name_lower, do: v
_ ->
nil
end)
end
def check_change_country(address, country_data) do
case Map.get(country_data, "change_country") do
nil ->
{:ok, address}
change_country ->
country =
Regex.replace(~r/\$([a-z_]+)/, change_country, fn _, field ->
Map.get(address, String.to_existing_atom(field)) || ""
end)
{:ok, %{address | country: country}}
end
end
def check_country_case(address, variables, _country_data) do
country_code = Map.get(variables, "country_code")
{:ok, Map.put(address, :country_code, String.upcase(country_code))}
end
def check_use_country(address, country_data) do
case Map.get(country_data, "use_country") do
nil -> {:ok, address}
country_code -> {:ok, %{address | country_code: String.upcase(country_code)}}
end
end
def upcase(nil), do: nil
def upcase(string), do: String.upcase(string)
def is_integer?(val) when is_integer(val), do: true
def is_integer?(val) when is_bitstring(val) do
Regex.match?(~r{\A\d+\z}, val)
end
def is_integer?(_), do: false
def adjust_value(value, :postcode) when is_bitstring(value) and byte_size(value) > 9, do: nil
def adjust_value(value, _key), do: value
def to_existing_atom(key, _) when is_atom(key), do: key
def to_existing_atom(key, default) do
String.to_existing_atom(key)
rescue
_e ->
IO.puts("#{key}: is not an existing atom")
default
end
defp safe_to_atom(key) when is_atom(key), do: key
defp safe_to_atom(key) do
String.to_existing_atom(key)
rescue
_ ->
IO.puts("#{key}: is not an existing atom")
nil
end
end