lib/datix/date_time.ex

defmodule Datix.DateTime do
  @moduledoc """
  A `DateTime` parser using `Calendar.strftime/3` format string.
  """

  alias Datix.ValidationError

  @doc """
  Parses a datetime string into a `DateTime` according to the given `format`.

  See the `Calendar.strftime/3` documentation for how to specify a format string.

  When the format string contains an offset (`%z`) or a timezone abbreviation (`%Z`),
  then the `:time_zone` option is required. See below for more information on the option.

  ## Options

    * `:calendar` - the calendar to build the `Date`, defaults to `Calendar.ISO`

    * `:preferred_date` - a string for the preferred format to show dates,
      it can't contain the `%x` format and defaults to `"%Y-%m-%d"`
      if the option is not received

    *  `:month_names` - a list of the month names, if the option is not received
      it defaults to a list of month names in English

    * `:abbreviated_month_names` - a list of abbreviated month names, if the
      option is not received it defaults to a list of abbreviated month names in
      English

    * `:day_of_week_names` - a list of day names, if the option is not received
      it defaults to a list of day names in English

    * `:abbreviated_day_of_week_names` - a list of abbreviated day names, if the
      option is not received it defaults to a list of abbreviated day names in
      English

    * `:preferred_time` - a string for the preferred format to show times,
      it can't contain the `%X` format and defaults to `"%H:%M:%S"`
      if the option is not received

    * `:am_pm_names` - a keyword list with the names of the period of the day,
      defaults to `[am: "am", pm: "pm"]`.

    * `:pivot_year` - (since v0.2.0) a 2-digit year that represents the *pivot year* to use when
      `%y` is used. `%y` represents a 2-digit year, but Datix doesn't assume anything
      about which *century* such year refers to. For this reason, the `:pivot_year`
      option is required whenever `%y` is present in the format string; if not
      present, this function returns `{:error, :missing_pivot_year_option}`.
      For example, if `pivot_year: 65`, then the 2-digit year `64` and lower will
      refer to the current century (`2064` and so on at the time of writing this),
      while the 2-digit year `65` and higher will refer to the previous century
      (`1965` and so on).

    * `:time_zone` - (since v0.3.0) a function that receives the parsed datetime as 
      `NaiveDateTime`, the zone abbreviation, and the offset to handle the time zone 
      and returns an `:ok` tuple with the `DateTime` or an `:error` tuple with 
      `Datix.ValidationError`. Defaults to a function handling only "UTC".

  ## Examples

      iex> Datix.DateTime.parse("2021/01/10 12:14:24", "%Y/%m/%d %H:%M:%S")
      {:ok, ~U[2021-01-10 12:14:24Z]}

      iex> format = Datix.compile!("%Y/%m/%d %H:%M:%S")
      iex> Datix.DateTime.parse("2021/01/10 12:14:24", format)
      {:ok, ~U[2021-01-10 12:14:24Z]}

      iex> Datix.DateTime.parse("2018/06/27 11:23:55 CEST+0200", "%Y/%m/%d %H:%M:%S %Z%z")
      {:error, %Datix.ValidationError{module: Datix.DateTime, reason: {:unknown_timezone_abbr, "CEST"}}}


  If you need to parse non-UTC datetimes, you'll have to pass the `:time_zone` option.

      tz_fun = fn naive_datetime, abbr, offset ->
        {:ok, naive_datetime |> DateTime.from_naive!(convert_abbr(abbr)) |> DateTime.add(-offset)}
      end

      Datix.DateTime.parse("2018/06/27 11:23:55 CEST+0200", "%Y/%m/%d %H:%M:%S %Z%z", time_zone: tz_fun)

  """
  @spec parse(String.t(), String.t() | Datix.compiled(), list()) ::
          {:ok, DateTime.t()}
          | {:error,
             Datix.FormatStringError.t()
             | Datix.ParseError.t()
             | Datix.ValidationError.t()
             | Datix.OptionError.t()}
  def parse(datetime_str, format, opts \\ []) do
    with {:ok, data} <- Datix.strptime(datetime_str, format, opts) do
      new(data, opts)
    end
  end

  @doc """
  Parses a datetime string according to the given `format`, erroring out for
  invalid arguments.

  This function is just defined for UTC datetimes.

  ## Options

  Accepts the same options as listed for `parse/3`.

  ## Examples

      iex> Datix.DateTime.parse!("2018/06/27 11:23:55 UTC+0000", "%Y/%m/%d %H:%M:%S %Z%z")
      ~U[2018-06-27 11:23:55Z]

      iex> format = Datix.compile!("%Y/%m/%d %H:%M:%S %Z%z")
      iex> Datix.DateTime.parse!("2018/06/27 11:23:55 UTC+0000", format)
      ~U[2018-06-27 11:23:55Z]

      iex> Datix.DateTime.parse!("2018/06/27 11:23:55 CEST+0200", "%Y/%m/%d %H:%M:%S %Z%z")
      ** (Datix.ValidationError) unknown timezone abbreviation: CEST

  """
  @spec parse!(String.t(), String.t() | Datix.compiled(), list()) :: DateTime.t()
  def parse!(datetime_str, format, opts \\ []) do
    case parse(datetime_str, format, opts) do
      {:ok, datetime} -> datetime
      {:error, error} when is_exception(error) -> raise error
    end
  end

  @doc false
  def new(data, opts) do
    with {:ok, date} <- Datix.Date.new(data, opts),
         {:ok, time} <- Datix.Time.new(data, opts) do
      time_zone_fun = Keyword.get(opts, :time_zone, &default_time_zone_fun/3)
      naive_datetime = NaiveDateTime.new!(date, time)
      time_zone_fun.(naive_datetime, Map.get(data, :zone_abbr), Map.get(data, :zone_offset))
    end
  end

  defp default_time_zone_fun(naive_datetime, zone_abbr, offset) do
    case {zone_abbr || "UTC", offset || 0} do
      {"UTC", 0} ->
        {:ok, DateTime.from_naive!(naive_datetime, "Etc/UTC")}

      {"UTC", offset} when is_integer(offset) and offset != 0 ->
        {:ok, naive_datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.add(-offset)}

      {abbr, _offset} ->
        {:error, %ValidationError{reason: {:unknown_timezone_abbr, abbr}, module: Datix.DateTime}}
    end
  end
end