defmodule Cldr.Time do
@moduledoc """
Provides localization and formatting of a time.
A time is any `t:Time.t/0` struct or any map with one or more of
the keys `:hour`, `:minute`, `:second` and optionally `:time_zone`,
`:zone_abbr`, `:utc_offset`, `:std_offset` and `:microsecond`.
`Cldr.Time` provides support for the built-in calendar
`Calendar.ISO` or any calendars defined with
[ex_cldr_calendars](https://hex.pm/packages/ex_cldr_calendars)
CLDR provides standard format strings for `Time` which
are represented by the names `:short`, `:medium`, `:long`
and `:full`. This allows for locale-independent
formatting since each locale may define the underlying
format string as appropriate.
"""
alias Cldr.LanguageTag
alias Cldr.Locale
import Cldr.DateTime,
only: [resolve_plural_format: 4, apply_preference: 2, has_time: 1]
@typep options :: Keyword.t() | map()
@format_types [:short, :medium, :long, :full]
@default_format_type :medium
@default_prefer :unicode
@field_map %{
hour: "h",
minute: "m",
second: "s",
time_zone: "v",
zone_abbr: "V"
}
@field_names Map.keys(@field_map)
# TODO Do we need microseconds here too? Are there any standard formats that use it?
# have we got the formatting right for fractional seconds?
# have we got derived formats working for microseconds?
defguard is_full_time(time)
when is_map_key(time, :hour) and is_map_key(time, :minute) and is_map_key(time, :second)
defmodule Formats do
@moduledoc false
defstruct Module.get_attribute(Cldr.Time, :format_types)
end
@doc """
Formats a time according to a format string
as defined in CLDR and described in [TR35](http://unicode.org/reports/tr35/tr35-dates.html).
### Returns
* `{:ok, formatted_time}` or
* `{:error, reason}`.
### Arguments
* `time` is a `t:Time.t/0` struct or any map that contains
one or more of the keys `:hour`, `:minute`, `:second` and optionally `:microsecond`,
`:time_zone`, `:zone_abbr`, `:utc_offset` and `:std_offset`.
* `backend` is any module that includes `use Cldr` and therefore
is a `Cldr` backend module. The default is `Cldr.default_backend!/0`.
* `options` is a keyword list of options for formatting.
### Options
* `:format` is one of `:short`, `:medium`, `:long`, `:full`, or a format id
or a format string. The default is `:medium` for full times (that is,
times having `:hour`, `:minute` and `:second` fields). The
default for partial times is to derive a candidate format from the time and
find the best match from the formats returned by
`Cldr.Time.available_formats/3`. See [here](README.md#date-time-and-datetime-localization-formats)
for more information about specifying formats.
* `:locale` any locale returned by `Cldr.known_locale_names/1`. The default is
`Cldr.get_locale/0`.
* `:number_system` a number system into which the formatted date digits should
be transliterated.
* `:prefer` expresses the preference for one of the possible alternative
sub-formats. See the variant preference notes below.
* `period: :variant` will use a variant for the time period and flexible time period if
one is available in the locale. For example, in the `:en` locale, `period: :variant` will
return "pm" instead of "PM".
### Variant Preference
* A small number of formats have one of two different alternatives, each with their own
preference specifier. The preferences are specified with the `:prefer` option to
`Cldr.Date.to_string/3`. The preference is expressed as an atom, or a list of one or two
atoms with one atom being either `:unicode` or `:ascii` and one atom being either
`:default` or `:variant`.
* Some formats (at the time of publishng only time formats but that
may change in the future) have `:unicode` and `:ascii` versions of the format. The
difference is the use of ascii space (0x20) as a separateor in the `:ascii` verison
whereas the `:unicode` version may use non-breaking or other space characters. The
default is `:unicode` and this is the strongly preferred option. The `:ascii` format
is primarily to support legacy use cases and is not recommended. See
`Cldr.Time.available_formats/3` to see which formats have these variants.
* Some formats (at the time of publishing, only date and datetime formats) have
`:default` and `:variant` versions of the format. These variant formats are only
included in a small number of locales. For example, the `:"en-CA"` locale, which has
a `:default` format respecting typical Canadian formatting and a `:variant` that is
more closely aligned to US formatting. The default is `:default`.
### Examples
iex> Cldr.Time.to_string(~T[07:35:13.215217], MyApp.Cldr)
{:ok, "7:35:13 AM"}
iex> Cldr.Time.to_string(~T[07:35:13.215217], MyApp.Cldr, format: :short)
{:ok, "7:35 AM"}
iex> Cldr.Time.to_string(~T[07:35:13.215217], MyApp.Cldr, format: :short, period: :variant)
{:ok, "7:35 am"}
iex> Cldr.Time.to_string(~T[07:35:13.215217], MyApp.Cldr, format: :medium, locale: "fr")
{:ok, "07:35:13"}
iex> Cldr.Time.to_string(~T[07:35:13.215217], MyApp.Cldr, format: :medium)
{:ok, "7:35:13 AM"}
iex> {:ok, datetime} = DateTime.from_naive(~N[2000-01-01 23:59:59.0], "Etc/UTC")
iex> Cldr.Time.to_string datetime, MyApp.Cldr, format: :long
{:ok, "11:59:59 PM UTC"}
# A partial time with a best match CLDR-defined format
iex> Cldr.Time.to_string(%{hour: 23, minute: 11})
{:ok, "11:11 PM"}
# Sometimes the available time fields can't be mapped to an available
# CLDR-defined format.
iex> Cldr.Time.to_string(%{minute: 11})
{:error,
{Cldr.DateTime.UnresolvedFormat, "No available format resolved for :m"}}
"""
@spec to_string(Cldr.Calendar.any_date_time(), Cldr.backend(), options()) ::
{:ok, String.t()} | {:error, {module, String.t()}}
@spec to_string(Cldr.Calendar.any_date_time(), options(), []) ::
{:ok, String.t()} | {:error, {module, String.t()}}
def to_string(time, backend \\ Cldr.Date.default_backend(), options \\ [])
def to_string(%{calendar: Calendar.ISO} = time, backend, options) do
%{time | calendar: Cldr.Calendar.Gregorian}
|> to_string(backend, options)
end
def to_string(time, options, []) when is_list(options) do
{locale, backend} = Cldr.locale_and_backend_from(options)
options = Keyword.put_new(options, :locale, locale)
to_string(time, backend, options)
end
def to_string(%{} = time, backend, options)
when is_atom(backend) and has_time(time) do
options = normalize_options(time, backend, options)
format_backend = Module.concat(backend, DateTime.Formatter)
calendar = Map.get(time, :calendar, Cldr.Calendar.Gregorian)
time = Map.put_new(time, :calendar, calendar)
number_system = Map.get(options, :number_system)
locale = options.locale
format = options.format
prefer = List.wrap(options.prefer)
with {:ok, locale} <- Cldr.validate_locale(locale, backend),
{:ok, cldr_calendar} <- Cldr.DateTime.type_from_calendar(calendar),
{:ok, _} <- Cldr.Number.validate_number_system(locale, number_system, backend),
{:ok, format} <- find_format(time, format, locale, cldr_calendar, backend),
{:ok, format} <- apply_preference(format, prefer),
{:ok, format_string} <- resolve_plural_format(format, time, backend, options) do
format_backend.format(time, format_string, locale, options)
end
rescue
e in [Cldr.DateTime.FormatError] ->
{:error, {e.__struct__, e.message}}
end
def to_string(time, value, []) when is_map(time) do
{:error, {ArgumentError, "Unexpected option value #{inspect value}. Options must be a keyword list"}}
end
def to_string(time, _backend, _options) do
error_return(time, [:hour, :minute, :second])
end
@doc """
Formats a time according to a format string
as defined in CLDR and described in [TR35](http://unicode.org/reports/tr35/tr35-dates.html).
### Arguments
* `time` is a `t:Time.t/0` struct or any map that contains
one or more of the keys `:hour`, `:minute`, `:second` and optionally `:microsecond`,
`:time_zone`, `:zone_abbr`, `:utc_offset` and `:std_offset`.
* `backend` is any module that includes `use Cldr` and therefore
is a `Cldr` backend module. The default is `Cldr.default_backend!/0`.
* `options` is a keyword list of options for formatting.
### Options
* `:format` is one of `:short`, `:medium`, `:long`, `:full`, or a format id
or a format string. The default is `:medium` for full times (that is,
times having `:hour`, `:minute` and `:second` fields). The
default for partial times is to derive a candidate format from the time and
find the best match from the formats returned by
`Cldr.Time.available_formats/3`. See [here](README.md#date-time-and-datetime-localization-formats)
for more information about specifying formats.
* `locale` is any valid locale name returned by `Cldr.known_locale_names/0`
or a `t:Cldr.LanguageTag.t/0` struct. The default is `Cldr.get_locale/0`.
* `:number_system` a number system into which the formatted time digits should
be transliterated.
* `:prefer` expresses the preference for one of the possible alternative
sub-formats. See the variant preference notes below.
* `period: :variant` will use a variant for the time period and flexible time period if
one is available in the locale. For example, in the `:en` locale `period: :variant` will
return "pm" instead of "PM".
### Variant Preference
* A small number of formats have one of two different alternatives, each with their own
preference specifier. The preferences are specified with the `:prefer` option to
`Cldr.Date.to_string/3`. The preference is expressed as an atom, or a list of one or two
atoms with one atom being either `:unicode` or `:ascii` and one atom being either
`:default` or `:variant`.
* Some formats (at the time of publishng only time formats but that
may change in the future) have `:unicode` and `:ascii` versions of the format. The
difference is the use of ascii space (0x20) as a separateor in the `:ascii` verison
whereas the `:unicode` version may use non-breaking or other space characters. The
default is `:unicode` and this is the strongly preferred option. The `:ascii` format
is primarily to support legacy use cases and is not recommended. See
`Cldr.Time.available_formats/3` to see which formats have these variants.
* Some formats (at the time of publishing, only date and datetime formats) have
`:default` and `:variant` versions of the format. These variant formats are only
included in a small number of locales. For example, the `:"en-CA"` locale, which has
a `:default` format respecting typical Canadian formatting and a `:variant` that is
more closely aligned to US formatting. The default is `:default`.
### Returns
* `formatted_time_string` or
* raises an exception.
### Examples
iex> Cldr.Time.to_string!(~T[07:35:13.215217], MyApp.Cldr)
"7:35:13 AM"
iex> Cldr.Time.to_string!(~T[07:35:13.215217], MyApp.Cldr, format: :short)
"7:35 AM"
iex> Cldr.Time.to_string!(~T[07:35:13.215217], MyApp.Cldr, format: :short, period: :variant)
"7:35 am"
iex> Cldr.Time.to_string!(~T[07:35:13.215217], MyApp.Cldr, format: :medium, locale: "fr")
"07:35:13"
iex> Cldr.Time.to_string!(~T[07:35:13.215217], MyApp.Cldr, format: :medium)
"7:35:13 AM"
iex> {:ok, datetime} = DateTime.from_naive(~N[2000-01-01 23:59:59.0], "Etc/UTC")
iex> Cldr.Time.to_string!(datetime, MyApp.Cldr, format: :long)
"11:59:59 PM UTC"
# A partial time with a best match CLDR-defined format
iex> Cldr.Time.to_string!(%{hour: 23, minute: 11})
"11:11 PM"
"""
@spec to_string!(Cldr.Calendar.any_date_time(), Cldr.backend(), options()) ::
String.t() | no_return()
@spec to_string!(Cldr.Calendar.any_date_time(), options(), []) ::
String.t() | no_return()
def to_string!(time, backend \\ Cldr.Date.default_backend(), options \\ [])
def to_string!(time, backend, options) do
case to_string(time, backend, options) do
{:ok, string} -> string
{:error, {exception, message}} -> raise exception, message
end
end
# TODO deprecate :style in version 3.0
defp normalize_options(_time, _backend, %{} = options) do
options
end
defp normalize_options(time, backend, []) do
{locale, _backend} = Cldr.locale_and_backend_from(nil, backend)
number_system = Cldr.Number.System.number_system_from_locale(locale, backend)
default_prefer = List.wrap(@default_prefer)
format = format_from_options(time, nil, @default_format_type, default_prefer)
%{locale: locale, number_system: number_system, format: format, prefer: default_prefer}
end
defp normalize_options(time, backend, options) do
{locale, _backend} = Cldr.locale_and_backend_from(options[:locale], backend)
locale_number_system = Cldr.Number.System.number_system_from_locale(locale, backend)
number_system = Keyword.get(options, :number_system, locale_number_system)
prefer = Keyword.get(options, :prefer, @default_prefer) |> List.wrap()
format_option = options[:time_format] || options[:format] || options[:style]
format = format_from_options(time, format_option, @default_format_type, prefer)
options
|> Map.new()
|> Map.put(:locale, locale)
|> Map.put(:format, format)
|> Map.put(:prefer, prefer)
|> Map.delete(:style)
|> Map.put_new(:number_system, number_system)
end
# Full date, no option, use the default format
defp format_from_options(time, nil, default_format, _prefer) when is_full_time(time) do
default_format
end
# Partial date, no option, derive the format from the date
defp format_from_options(time, nil, _default_format, _prefer) do
derive_format_id(time)
end
# If a format is requested, use it
defp format_from_options(_time, format, _default_format, prefer) do
{:ok, format} = apply_preference(format, prefer)
format
end
@doc false
def derive_format_id(time) do
Cldr.DateTime.derive_format_id(time, @field_map, @field_names)
end
# If its a full time we can use one of the standard formats (:short, :medium, :long)
# and if its a full date and no format is specified then the default :medium will be
# applied.
@doc false
def find_format(time, format, locale, calendar, backend)
when format in @format_types and is_full_time(time) do
%LanguageTag{cldr_locale_name: locale_name} = locale
with {:ok, time_formats} <- formats(locale_name, calendar, backend) do
{:ok, Map.fetch!(time_formats, format)}
end
end
# If its a partial date and a standard format is requested, its an error
def find_format(time, format, _locale, _calendar, _backend)
when format in @format_types and not is_full_time(time) do
{:error,
{
Cldr.DateTime.UnresolvedFormat,
"Standard formats are not accepted for partial times"
}}
end
def find_format(time, %{format: format} = format_map, locale, calendar, backend) do
%{number_system: number_system} = format_map
{:ok, format_string} = find_format(time, format, locale, calendar, backend)
{:ok, %{number_system: number_system, format: format_string}}
end
# If its an atom format it means we want to use one of the available formats. Since
# these are map keys they can be used in a locale-independent way. If the requested
# format is a direct match, use it. If not - try to find the best match between the
# requested format and available formats.
def find_format(_time, format, locale, calendar, backend) when is_atom(format) do
{:ok, available_formats} = available_formats(locale, calendar, backend)
if Map.has_key?(available_formats, format) do
Map.fetch(available_formats, format)
else
resolve_format(format, available_formats, locale, calendar, backend)
end
end
# If its a binary then its considered a format string so we use
# it directly.
def find_format(_time, format_string, _locale, _calendar, _backend)
when is_binary(format_string) do
{:ok, format_string}
end
@doc false
defdelegate resolve_format(format, available_formats, locale, calendar, backend), to: Cldr.Date
@doc """
Returns a map of the standard time formats for a given
locale and calendar.
### Arguments
* `locale` is any locale returned by `Cldr.known_locale_names/0`
or a `t:Cldr.LanguageTag.t/0`. The default is `Cldr.get_locale/0`.
* `calendar` is any calendar returned by `Cldr.DateTime.Format.calendars_for/1`
The default is `:gregorian`.
* `backend` is any module that includes `use Cldr` and therefore
is a `Cldr` backend module. The default is `Cldr.default_backend/0`.
### Examples:
iex> Cldr.Time.formats(:en, :gregorian, MyApp.Cldr)
{
:ok,
%Cldr.Time.Formats{
full: %{unicode: "h:mm:ss a zzzz", ascii: "h:mm:ss a zzzz"},
long: %{unicode: "h:mm:ss a z", ascii: "h:mm:ss a z"},
medium: %{unicode: "h:mm:ss a", ascii: "h:mm:ss a"},
short: %{unicode: "h:mm a", ascii: "h:mm a"}
}
}
iex> Cldr.Time.formats(:en, :buddhist, MyApp.Cldr)
{
:ok,
%Cldr.Time.Formats{
full: %{unicode: "h:mm:ss a zzzz", ascii: "h:mm:ss a zzzz"},
long: %{unicode: "h:mm:ss a z", ascii: "h:mm:ss a z"},
medium: %{unicode: "h:mm:ss a", ascii: "h:mm:ss a"},
short: %{unicode: "h:mm a", ascii: "h:mm a"}
}
}
"""
@spec formats(
Locale.locale_reference(),
Cldr.Calendar.calendar(),
Cldr.backend()
) ::
{:ok, Cldr.DateTime.Format.standard_formats()} | {:error, {atom, String.t()}}
def formats(
locale \\ Cldr.get_locale(),
calendar \\ Cldr.Calendar.default_cldr_calendar(),
backend \\ Cldr.Date.default_backend()
) do
Cldr.DateTime.Format.time_formats(locale, calendar, backend)
end
@doc """
Returns a map of the available date formats for a
given locale and calendar.
### Arguments
* `locale` is any locale returned by `Cldr.known_locale_names/0`
or a `t:Cldr.LanguageTag.t/0`. The default is `Cldr.get_locale/0`.
* `calendar` is any calendar returned by `Cldr.DateTime.Format.calendars_for/1`
The default is `:gregorian`.
* `backend` is any module that includes `use Cldr` and therefore
is a `Cldr` backend module. The default is `Cldr.default_backend/0`.
### Examples:
iex> Cldr.Time.available_formats(:en)
{:ok,
%{
h: %{unicode: "h a", ascii: "h a"},
hms: %{unicode: "h:mm:ss a", ascii: "h:mm:ss a"},
ms: "mm:ss",
H: "HH",
Hm: "HH:mm",
Hms: "HH:mm:ss",
Hmsv: "HH:mm:ss v",
Hmv: "HH:mm v",
hm: %{unicode: "h:mm a", ascii: "h:mm a"},
hmsv: %{unicode: "h:mm:ss a v", ascii: "h:mm:ss a v"},
hmv: %{unicode: "h:mm a v", ascii: "h:mm a v"}
}}
"""
@spec available_formats(
Locale.locale_reference(),
Cldr.Calendar.calendar(),
Cldr.backend()
) :: {:ok, map()} | {:error, {atom, String.t()}}
def available_formats(
locale \\ Cldr.get_locale(),
calendar \\ Cldr.Calendar.default_cldr_calendar(),
backend \\ Cldr.Date.default_backend()
) do
backend = Module.concat(backend, DateTime.Format)
backend.time_available_formats(locale, calendar)
end
@doc """
Return the preferred time format for a locale.
### Arguments
* `language_tag` is any language tag returned by `Cldr.Locale.new/2`
or any `locale_name` returned by `Cldr.known_locale_names/1`
### Returns
* The hour format as an atom to be used for localization purposes. The
return value is used as a function name in `Cldr.DateTime.Formatter`
### Notes
* The `hc` key of the `u` extension is honoured and will
override the default preferences for a locale or territory.
See the last example below.
* Different locales and territories present the hour
of day in different ways. These are represented
in `Cldr.DateTime.Formatter` in the following way:
| Symbol | Midn. | Morning | Noon | Afternoon | Midn. |
| :----: | :---: | :-----: | :--: | :--------: | :---: |
| h | 12 | 1...11 | 12 | 1...11 | 12 |
| K | 0 | 1...11 | 0 | 1...11 | 0 |
| H | 0 | 1...11 | 12 | 13...23 | 0 |
| k | 24 | 1...11 | 12 | 13...23 | 24 |
### Examples
iex> Cldr.Time.hour_format_from_locale("en-AU")
:h12
iex> Cldr.Time.hour_format_from_locale("fr")
:h23
iex> Cldr.Time.hour_format_from_locale("fr-u-hc-h12")
:h12
"""
def hour_format_from_locale(%LanguageTag{locale: %{hc: hour_cycle}})
when not is_nil(hour_cycle) do
hour_cycle
end
def hour_format_from_locale(%LanguageTag{} = locale) do
preferences = time_preferences()
territory = Cldr.Locale.territory_from_locale(locale)
preference =
preferences[locale.cldr_locale_name] ||
preferences[territory] ||
preferences[Cldr.the_world()]
Map.fetch!(time_symbols(), preference.preferred)
end
def hour_format_from_locale(locale_name, backend \\ Cldr.Date.default_backend()) do
with {:ok, locale} <- Cldr.validate_locale(locale_name, backend) do
hour_format_from_locale(locale)
end
end
@doc false
@time_preferences Cldr.Config.time_preferences()
def time_preferences do
@time_preferences
end
# | Symbol | Midn. | Morning | Noon | Afternoon | Midn. | Code
# | :----: | :---: | :-----: | :--: | :--------: | :---: | :--:
# | h | 12 | 1...11 | 12 | 1...11 | 12 | :h12
# | K | 0 | 1...11 | 0 | 1...11 | 0 | :h11
# | H | 0 | 1...11 | 12 | 13...23 | 0 | :h23
# | k | 24 | 1...11 | 12 | 13...23 | 24 | :h24
#
defp time_symbols do
%{
# :hour_1_12,
"h" => :h12,
# :hour_0_11,
"K" => :h11,
# :hour_0_23,
"H" => :h23,
# :hour_1_24.
"k" => :h24
}
end
defp error_return(map, requirements) do
requirements =
requirements
|> Enum.map(&inspect/1)
|> Cldr.DateTime.Formatter.join_requirements()
{:error,
{ArgumentError,
"Invalid time. Time is a map that contains at least #{requirements} fields. " <>
"Found: #{inspect(map)}"}}
end
end