defmodule Localize.DateTime do @moduledoc """ Provides localized formatting of `DateTime`, `NaiveDateTime`, and datetime-like maps. The primary function is `to_string/2` which accepts a datetime value and an options keyword list. Format patterns are defined in CLDR and described in [TR35](http://unicode.org/reports/tr35/tr35-dates.html). ## Predefined formats * `:short` — abbreviated date and time (e.g., "1/2/25, 3:04 PM"). * `:medium` — standard date and time (default). * `:long` — includes time zone name. * `:full` — verbose day-of-week, date, and time zone. Custom CLDR skeleton strings and raw format patterns are also supported via the `:format` option. """ import Kernel, except: [to_string: 1] @default_format :medium @standard_formats [:short, :medium, :long, :full] @doc """ Formats a datetime according to a CLDR format pattern. ### Arguments * `datetime` is a `t:DateTime.t/0`, `t:NaiveDateTime.t/0`, or any map with date and time keys. * `options` is a keyword list of options. ### Options * `:format` is a standard format name (`:short`, `:medium`, `:long`, `:full`) or a format pattern string. The default is `:medium`. * `:locale` is a locale identifier. The default is `:en`. * `:prefer` is `:unicode` or `:ascii` for variant selection. The default is `:unicode`. ### Returns * `{:ok, formatted_string}` on success. * `{:error, exception}` if the datetime cannot be formatted. ### Examples iex> Localize.DateTime.to_string(~N[2017-07-10 14:30:00], locale: :en, prefer: :ascii) {:ok, "Jul 10, 2017, 2:30:00 PM"} iex> Localize.DateTime.to_string(~N[2017-07-10 14:30:00], format: :short, locale: :en, prefer: :ascii) {:ok, "7/10/17, 2:30 PM"} """ @spec to_string(map(), Keyword.t()) :: {:ok, String.t()} | {:error, Exception.t()} def to_string(datetime, options \\ []) # Full datetime: year, month, day, hour, minute, second all present. def to_string( %{year: _, month: _, day: _, hour: _, minute: _, second: _} = datetime, options ) do locale = Keyword.get(options, :locale, Localize.get_locale()) format = Keyword.get(options, :format, @default_format) style = Keyword.get(options, :style, :default) with {:ok, locale_id} <- resolve_locale_id(locale) do cond do # Explicit pattern string — format directly is_binary(format) -> Localize.DateTime.Formatter.format(datetime, format, locale_id, Map.new(options)) # Standard format with separate date/time formats — use wrapper format in @standard_formats or (Keyword.has_key?(options, :date_format) and Keyword.has_key?(options, :time_format)) -> format_with_wrapper(datetime, options, locale_id, format, style) # Skeleton atom — resolve to a pattern from available_formats is_atom(format) -> format_with_skeleton(datetime, options, locale_id, format) true -> {:error, Localize.DateTimeFormatError.exception(format: format, reason: :invalid_format)} end end end # Partial datetime: full date plus at least one time field but not all. # Render the date and time portions independently (each deriving a # skeleton from the fields actually present, via the existing # `Localize.Date` / `Localize.Time` partial paths) and compose them # with the locale's datetime wrapper pattern. def to_string(%{year: _, month: _, day: _, hour: _} = datetime, options) do format_partial_datetime(datetime, options) end def to_string(datetime, options) when is_map(datetime) do # Try as date-only or time-only cond do Map.has_key?(datetime, :year) and Map.has_key?(datetime, :month) -> Localize.Date.to_string(datetime, options) Map.has_key?(datetime, :hour) -> Localize.Time.to_string(datetime, options) true -> {:error, Localize.DateTimeInvalidInputError.exception(type: :datetime)} end end def to_string(_invalid, _options) do {:error, Localize.DateTimeInvalidInputError.exception(type: :datetime)} end @doc """ Same as `to_string/2` but raises on error. """ @spec to_string!(map(), Keyword.t()) :: String.t() def to_string!(datetime, options \\ []) do case to_string(datetime, options) do {:ok, string} -> string {:error, exception} -> raise exception end end defp format_partial_datetime(datetime, options) do locale = Keyword.get(options, :locale, Localize.get_locale()) format = Keyword.get(options, :format, @default_format) date_format = Keyword.get(options, :date_format, format) time_format = Keyword.get(options, :time_format, format) # The wrapper level follows the date format when explicit, otherwise # the requested top-level format. Non-standard atoms fall back to # `:medium` for wrapper selection only. wrapper_level = cond do date_format in @standard_formats -> date_format format in @standard_formats -> format true -> :medium end date_only = Map.drop(datetime, [:hour, :minute, :second, :microsecond]) time_only = datetime |> Map.take([:hour, :minute, :second, :microsecond, :calendar]) date_options = Keyword.put(options, :format, date_format) time_options = Keyword.put(options, :format, time_format) with {:ok, locale_id} <- resolve_locale_id(locale), {:ok, date_str} <- Localize.Date.to_string(date_only, date_options), {:ok, time_str} <- Localize.Time.to_string(time_only, time_options) do wrapper = fallback_wrapper(wrapper_level, locale_id) result = wrapper |> String.replace("{1}", date_str) |> String.replace("{0}", time_str) {:ok, result} end end defp format_with_wrapper(datetime, options, locale_id, format, style) do date_format = Keyword.get(options, :date_format, format) time_format = Keyword.get(options, :time_format, format) # The wrapper style should match the date format level # (e.g., full date + short time → use full wrapper) wrapper_format = if Keyword.has_key?(options, :date_format), do: date_format, else: format options_map = options |> Map.new() |> Map.put(:date_format, date_format) |> Map.put(:time_format, time_format) with {:ok, wrapper} <- resolve_wrapper(wrapper_format, locale_id, style) do Localize.DateTime.Formatter.format(datetime, wrapper, locale_id, options_map) end end defp format_with_skeleton(datetime, options, locale_id, skeleton) do prefer = Keyword.get(options, :prefer, :unicode) with {:ok, available} <- Localize.DateTime.Format.available_formats(locale_id) do case Map.get(available, skeleton) do nil -> # Try best-match algorithm for skeletons not found exactly case Localize.DateTime.Format.Match.best_match(skeleton, locale_id) do {:ok, matched_skeleton} when is_atom(matched_skeleton) -> case Map.get(available, matched_skeleton) do nil -> {:error, Localize.DateTimeUnresolvedFormatError.exception( format: skeleton, locale: locale_id )} matched_pattern -> format_resolved_pattern( resolve_prefer(matched_pattern, prefer), datetime, options, locale_id, skeleton ) end {:ok, {date_skeleton, time_skeleton}} -> date_pattern = resolve_prefer(Map.get(available, date_skeleton, ""), prefer) time_pattern = resolve_prefer(Map.get(available, time_skeleton, ""), prefer) if is_binary(date_pattern) and is_binary(time_pattern) do options_map = options |> Map.new() |> Map.put(:date_format, :medium) |> Map.put(:time_format, :medium) with {:ok, wrapper} <- resolve_wrapper(:medium, locale_id, :default) do combined = String.replace(wrapper, "{0}", time_pattern) combined = String.replace(combined, "{1}", date_pattern) Localize.DateTime.Formatter.format(datetime, combined, locale_id, options_map) end else {:error, Localize.DateTimeUnresolvedFormatError.exception( format: skeleton, locale: locale_id )} end _ -> {:error, Localize.DateTimeUnresolvedFormatError.exception( format: skeleton, locale: locale_id )} end %{} = variant_map -> format_resolved_pattern( resolve_prefer(variant_map, prefer), datetime, options, locale_id, skeleton ) pattern when is_binary(pattern) -> Localize.DateTime.Formatter.format(datetime, pattern, locale_id, Map.new(options)) end end end defp format_resolved_pattern(nil, _datetime, _options, locale_id, skeleton) do {:error, Localize.DateTimeUnresolvedFormatError.exception( format: skeleton, locale: locale_id )} end defp format_resolved_pattern(pattern, datetime, options, locale_id, _skeleton) when is_binary(pattern) do Localize.DateTime.Formatter.format(datetime, pattern, locale_id, Map.new(options)) end defp resolve_prefer(%{} = variant_map, prefer) do cond do # `:unicode` / `:ascii` prefer-style variant. Map.has_key?(variant_map, :unicode) or Map.has_key?(variant_map, :ascii) -> Map.get(variant_map, prefer) || Map.get(variant_map, :unicode) || Map.get(variant_map, :ascii) # CLDR plural-keyed variant (`:zero`, `:one`, `:two`, `:few`, # `:many`, `:other`). These appear in skeleton-derived formats # such as `MMMMW` ("week W of MMMM"). Without knowing the # numeric value at this stage we fall back to the `:other` # category, which CLDR guarantees to be present. Map.has_key?(variant_map, :other) -> Map.get(variant_map, :other) true -> nil end end defp resolve_prefer(pattern, _prefer) when is_binary(pattern), do: pattern defp resolve_prefer(_other, _prefer), do: nil defp resolve_wrapper(format, locale_id, style) do standard_format = if is_atom(format), do: format, else: :medium case style do :at -> # Use at-style format (e.g., "{1} 'at' {0}") with {:ok, at_formats} <- Localize.DateTime.Format.date_time_at_formats(locale_id) do pattern = get_in(at_formats, [:standard, standard_format]) || fallback_wrapper(standard_format, locale_id) {:ok, pattern} else _ -> {:ok, fallback_wrapper(standard_format, locale_id)} end _ -> # Use standard wrapper format (e.g., "{1}, {0}") {:ok, fallback_wrapper(standard_format, locale_id)} end end defp fallback_wrapper(standard_format, locale_id) do case Localize.DateTime.Format.date_time_formats(locale_id) do {:ok, dt_formats} -> Map.get(dt_formats, standard_format, "{1}, {0}") _ -> "{1}, {0}" end end defp resolve_locale_id(%Localize.LanguageTag{cldr_locale_id: id}), do: {:ok, id} defp resolve_locale_id(locale) when is_atom(locale) or is_binary(locale) do case Localize.validate_locale(locale) do {:ok, tag} -> {:ok, tag.cldr_locale_id} error -> error end end defp resolve_locale_id(invalid) do {:error, Localize.InvalidLocaleError.exception(locale_id: inspect(invalid))} end end