defmodule Cldr.Unit.Additional do @moduledoc """ Additional domain-specific units can be defined to suit application requirements. In the context of `ex_cldr` there are two parts of configuring additional units. 1. Configure the unit, base unit and conversion in `config.exs`. This is a requirement since units are compiled into code. 2. Configure the localizations for the additional unit in a CLDR backend module. Once configured, additional units act and behave like any of the predefined units of measure defined by CLDR. ## Configuring a unit in config.exs Under the application `:ex_cldr_units` define a key `:additional_units` with the required unit definitions. For example: ```elixir config :ex_cldr_units, :additional_units, vehicle: [base_unit: :unit, factor: 1, offset: 0, sort_before: :all], person: [base_unit: :unit, factor: 1, offset: 0, sort_before: :all] ``` This example defines two additional units: `:vehicle` and `:person`. The keys `:base_unit`, and `:factor` are required. The key `:offset` is optional and defaults to `0`. The key `:sort_before` is optional and defaults to `:none`. ### Configuration keys * `:base_unit` is the common denominator that is used to support conversion between like units. It can be any atom value. For example `:liter` is the base unit for volume units, `:meter` is the base unit for length units. * `:factor` is used to convert a unit to its base unit in order to support conversion. When converting a unit to another compatible unit, the unit is first multiplied by this units factor then divided by the target units factor. * `:offset` is added to a unit after applying its base factor in order to convert to another unit. * `:sort_before` determines where in this *base unit* sorts relative to other base units. Typically this is set to `:all` in which case this base unit sorts before all other base units or`:none` in which case this base unit sorted after all other base units. The default is `:none`. If in doubt, leave this key to its default. * `:systems` is list of measurement systems to which this unit belongs. The known measurement systems are `:metric`, `:uksystem` and `:ussystem`. The default is `[:metric, :ussystem, :uksystem]`. ## Defining localizations Localizations are defined in a backend module through adding `use Cldr.Unit.Additional` to the top of the backend module and invoking `Cldr.Unit.Additional.unit_localization/4` for each localization. See `Cldr.Unit.Additional.unit_localization/4` for further information. Note that one invocation of the macro is required for each combination of locale, style and unit. An exception will be raised at runtime is a localization is expected but is not found. """ defmacro __using__(_opts) do module = __CALLER__.module quote do @before_compile Cldr.Unit.Additional @after_compile Cldr.Unit.Additional import Cldr.Unit.Additional Module.register_attribute(unquote(module), :custom_localizations, accumulate: true) end end @doc false defmacro __before_compile__(_ast) do caller = __CALLER__.module target_module = Module.concat(caller, Unit.Additional) caller |> Module.get_attribute(:custom_localizations) |> Cldr.Unit.Additional.group_localizations() |> Cldr.Unit.Additional.define_localization_module(target_module) end @doc """ Although defining a unit in `config.exs` is enough to create, operate on and serialize an additional unit, it cannot be localised without defining localizations in an `ex_cldr` backend module. For example: ```elixir defmodule MyApp.Cldr do # Note that this line should come before the `use Cldr` line use Cldr.Unit.Additional use Cldr, locales: ["en", "fr", "de", "bs", "af", "af-NA", "se-SE"], default_locale: "en", providers: [Cldr.Number, Cldr.Unit, Cldr.List] unit_localization(:person, "en", :long, one: "{0} person", other: "{0} people", display_name: "people" ) unit_localization(:person, "en", :short, one: "{0} per", other: "{0} pers", display_name: "people" ) unit_localization(:person, "en", :narrow, one: "{0} p", other: "{0} p", display_name: "p" ) end ``` Note the additions to a typical `ex_cldr` backend module: * `use Cldr.Unit.Additional` is required to define additional units * use of the `unit_localization/4` macro in order to define a localization. One invocation of `unit_localization` should made for each combination of unit, locale and style. ### Parameters to unit_localization/4 * `unit` is the name of the additional unit as an `atom`. * `locale` is the locale name for this localization. It should be one of the locale configured in this backend although this cannot currently be confirmed at compile tiem. * `style` is one of `:long`, `:short`, or `:narrow`. * `localizations` is a keyword like of localization strings. Two keys - `:display_name` and `:other` are mandatory. They represent the localizations for a non-count display name and `:other` is the localization for a unit when no other pluralization is defined. ### Localisations Localization keyword list defines localizations that match the plural rules for a given locale. Plural rules for a given number in a given locale resolve to one of six keys: * `:zero` * `:one` (singular) * `:two` (dual) * `:few` (paucal) * `:many` (also used for fractions if they have a separate class) * `:other` (required—general plural form—also used if the language only has a single form) Only the `:other` key is required. For english, providing keys for `:one` and `:other` is enough. Other languages have different grammatical requirements. The key `:display_name` is used by the function `Cldr.Unit.display_name/1` which is primarly used to support UI applications. """ defmacro unit_localization(unit, locale, style, localizations) do module = __CALLER__.module {localizations, _} = Code.eval_quoted(localizations) localization = Cldr.Unit.Additional.validate_localization!(unit, locale, style, localizations) quote do Module.put_attribute( unquote(module), :custom_localizations, unquote(Macro.escape(localization)) ) end end # This is the empty module created if the backend does not # include `use Cldr.Unit.Additional` @doc false def define_localization_module(localizations, module) do quote bind_quoted: [module: module, localizations: Macro.escape(localizations)] do defmodule module do for {locale, styles} <- localizations do for {style, formats} <- styles do def units_for(unquote(locale), unquote(style)) do unquote(Macro.escape(formats)) end end end def units_for(locale, style) do %{} end def known_locale_names do unquote(Map.keys(localizations)) end end end end # These locales are required for the correct operation of # ex_cldr but they are not considered part of the users # configuration @dont_consider_these_locales ["root", "en-001"] @doc false def __after_compile__(env, _bytecode) do additional_module = Module.concat(env.module, Unit.Additional) additional_units = MapSet.new(Cldr.Unit.Additional.additional_units()) additional_locales = MapSet.new(additional_module.known_locale_names()) backend_locales = MapSet.new(env.module.known_locale_names() -- @dont_consider_these_locales) styles = Cldr.Unit.styles() case MapSet.to_list(MapSet.difference(backend_locales, additional_locales)) do [] -> :ok other -> IO.warn( "The locales #{inspect(other)} configured in " <> "the CLDR backend #{inspect(env.module)} " <> "do not have localizations defined in #{inspect(additional_module)}", [] ) end for locale <- MapSet.intersection(backend_locales, additional_locales), style <- styles do case additional_module.units_for(locale, style) do map when map == %{} -> IO.warn( "#{inspect(additional_module)} does not define localizations " <> "for locale #{inspect(locale)} with style #{inspect(style)}", [] ) other -> localized_units = MapSet.new(Map.keys(other)) case MapSet.to_list(MapSet.difference(additional_units, localized_units)) do [] -> :ok other -> IO.warn( "#{inspect(additional_module)} does not define localizations " <> "for the units #{inspect(other)} in " <> "locale #{inspect(locale)} with style #{inspect(style)}", [] ) end end end end @doc false def group_localizations(localizations) when is_list(localizations) do localizations |> Enum.group_by( fn localization -> localization.locale end, fn localization -> Map.take(localization, [:style, :unit, :localizations]) end ) |> Enum.map(fn {locale, rest} -> value = Enum.group_by( rest, fn localization -> localization.style end, fn localization -> {localization.unit, parse(localization.localizations)} end ) |> Enum.map(fn {style, list} -> {style, Map.new(list)} end) {locale, Map.new(value)} end) |> Map.new() end defp parse(localizations) do Enum.map(localizations, fn {:display_name, name} -> {:display_name, name} {key, value} -> {key, Cldr.Substitution.parse(value)} end) |> Map.new() end @doc false def validate_localization!(unit, locale, style, localizations) do unless is_atom(unit) do raise ArgumentError, "Unit name must be an atom. Found #{inspect(unit)}" end unless style in [:short, :long, :narrow] do raise ArgumentError, "Style must be one of :short, :long or :narrow. Found #{inspect(style)}" end unless is_binary(locale) do raise ArgumentError, "Locale name must be a string. Found #{inspect(locale)}" end unless Keyword.keyword?(localizations) do raise ArgumentError, "Localizations must be a keyword list. Found #{inspect(localizations)}" end unless Keyword.has_key?(localizations, :other) do raise ArgumentError, "Localizations must have an :other key" end unless Keyword.has_key?(localizations, :display_name) do raise ArgumentError, "Localizations must have a :display_name key" end %{unit: unit, locale: locale, style: style, localizations: localizations} end @doc false @default_systems [:metric, :uksystem, :ussystem] @default_sort_before :none @default_offset 0 def conversions do Application.get_env(:ex_cldr_units, :additional_units, []) |> validate_keyword_list!() |> Enum.map(fn {unit, config} -> new_config = config |> Keyword.put_new(:offset, @default_offset) |> Keyword.put_new(:sort_before, @default_sort_before) |> Keyword.put_new(:systems, @default_systems) |> validate_unit! {unit, new_config} end) end defp validate_keyword_list!(list) do unless Keyword.keyword?(list) do raise ArgumentError, "Additional unit configuration must be a keyword list. See Cldr.Unit.Addition" end Enum.each(list, fn {k, v} -> unless Keyword.keyword?(v) do raise ArgumentError, "Additional unit configuration for #{inspect(k)} must be a keyword list. See Cldr.Unit.Addition" end end) list end defp validate_unit!(unit) do unless Keyword.keyword?(unit) do raise ArgumentError, "Additional unit configuration must be a keyword list. Found #{inspect(unit)}" end unless Keyword.has_key?(unit, :factor) do raise ArgumentError, "Additional unit configuration must have a :factor configured" end unless (list = Keyword.fetch!(unit, :systems)) |> is_list() do raise ArgumentError, "Additional unit systems must be a list. Found #{inspect(list)}" end unless Enum.all?(Keyword.fetch!(unit, :systems), &(&1 in @default_systems)) do raise ArgumentError, "Additional unit valid measurement systems are " <> "#{inspect(@default_systems)}. Found #{inspect(Keyword.fetch!(unit, :systems))}" end unless (base = Keyword.fetch!(unit, :base_unit)) |> is_atom() do raise ArgumentError, "Additional unit :base_unit must be an atom. Found #{inspect(base)}" end case Keyword.fetch!(unit, :factor) do x when is_number(x) -> :ok %{numerator: numerator, denominator: denominator} when is_number(numerator) and is_number(denominator) -> :ok other -> raise ArgumentError, "Additional unit factor must be a number or a rational " <> "of the form %{numerator: number, denominator: number}. Found #{inspect(other)}" end unit end @doc false def additional_units do conversions() |> Keyword.keys() |> Enum.sort() end @doc false def systems_for_units do conversions() |> Enum.map(fn {k, v} -> {k, v[:systems]} end) end @doc false def merge_base_units(core_base_units) do additional_base_units = orderable_base_units() |> Enum.reject(fn {k, _v} -> Keyword.has_key?(core_base_units, k) end) merge_base_units(core_base_units, additional_base_units) end def merge_base_units(core_base_units, additional_base_units, acc \\ []) # Insert units at the head def merge_base_units(core_base_units, [{k, :all} | rest], acc) do merge_base_units(core_base_units, rest, [{k, k} | acc]) end # Insert units at the tail. Since the additional units are sorted # we can guarantee that when we hit one with :none we can just take # everything left def merge_base_units(core_base_units, [{_k, :none} | _rest] = additional, acc) do tail_base_units = Enum.map(additional, fn {k, _v} -> {k, k} end) acc ++ core_base_units ++ tail_base_units end def merge_base_units(core_base_units, [], acc) do acc ++ core_base_units end def merge_base_units([], additional, acc) do tail_base_units = Enum.map(additional, fn {k, _v} -> {k, k} end) acc ++ tail_base_units end def merge_base_units([{k1, _v1} = head | other] = core_base_units, additional, acc) do case Keyword.pop(additional, k1) do {nil, _rest} -> merge_base_units(other, additional, acc ++ [head]) {{v2, _}, rest} -> merge_base_units(core_base_units, rest, acc ++ [{v2, v2}]) end end @doc false def base_units do conversions() |> Enum.map(fn {_k, v} -> {v[:base_unit], v[:base_unit]} end) |> Enum.uniq() |> Keyword.new() end @doc false def orderable_base_units do conversions() |> Enum.sort(fn {_k1, v1}, {_k2, v2} -> cond do Keyword.get(v1, :sort_before) == :all -> true Keyword.get(v1, :sort_before) == :none -> false Keyword.get(v1, :sort_before) < Keyword.get(v2, :sort_before) end end) |> Keyword.values() |> Enum.map(&{&1[:base_unit], &1[:sort_before]}) |> Enum.uniq() |> Keyword.new() end end