defmodule Datix.Date do
@moduledoc """
A `Date` parser using `Calendar.strftime/3` format strings.
"""
alias Datix.{OptionError, ValidationError}
@doc """
Parses a date string according to the given `format`.
See the `Calendar.strftime/3` documentation for how to specify a format-string.
## 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
* `:pivot_year` - 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, %Datix.OptionError{}}`.
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).
Missing values will be set to minimum.
## Examples
iex> Datix.Date.parse("2022-05-11", "%x")
{:ok, ~D[2022-05-11]}
iex> Datix.Date.parse("2021/01/10", "%Y/%m/%d")
{:ok, ~D[2021-01-10]}
iex> format = Datix.compile!("%Y/%m/%d")
iex> Datix.Date.parse("2021/01/10", format)
{:ok, ~D[2021-01-10]}
iex> Datix.Date.parse("2021/01/10", "%x", preferred_date: "%Y/%m/%d")
{:ok, ~D[2021-01-10]}
iex> Datix.Date.parse("18", "%y", pivot_year: 50)
{:ok, ~D[2018-01-01]}
iex> Datix.Date.parse("18", "%y", pivot_year: 15)
{:ok, ~D[1918-01-01]}
iex> Datix.Date.parse("18", "%y")
{:error, %Datix.OptionError{reason: :missing, option: :pivot_year}}
iex> Datix.Date.parse("", "")
{:ok, ~D[0000-01-01]}
iex> Datix.Date.parse("1736/13/03", "%Y/%m/%d", calendar: Coptic)
{:ok, ~D[1736-13-03 Cldr.Calendar.Coptic]}
iex> Datix.Date.parse("Mi, 1.4.2020", "%a, %-d.%-m.%Y",
...> abbreviated_day_of_week_names: ~w(Mo Di Mi Do Fr Sa So))
{:ok, ~D[2020-04-01]}
iex> Datix.Date.parse("Fr, 1.4.2020", "%a, %-d.%-m.%Y",
...> abbreviated_day_of_week_names: ~w(Mo Di Mi Do Fr Sa So))
{:error, %Datix.ValidationError{reason: :invalid_date, module: Datix.Date}}
"""
@spec parse(String.t(), String.t() | Datix.compiled(), list()) ::
{:ok, Date.t()}
| {:error,
Datix.FormatStringError.t()
| Datix.ParseError.t()
| Datix.ValidationError.t()
| Datix.OptionError.t()}
def parse(date_str, format, opts \\ []) do
with {:ok, data} <- Datix.strptime(date_str, format, opts) do
new(data, opts)
end
end
@doc """
Parses a date string according to the given `format`, erroring out for
invalid arguments.
## Options
Accepts the same options as listed for `parse/3`.
"""
@spec parse!(String.t(), String.t() | Datix.compiled(), list()) :: Date.t()
def parse!(date_str, format, opts \\ []) do
case parse(date_str, format, opts) do
{:ok, date} -> date
{:error, error} when is_exception(error) -> raise error
end
end
@doc false
def new(%{year: year, month: month, day: day} = data, opts) do
case Date.new(year, month, day, Datix.calendar(opts)) do
{:ok, date} ->
validate(date, data)
{:error, :invalid_date} ->
{:error, %ValidationError{reason: :invalid_date, module: __MODULE__}}
end
end
def new(%{year_2_digit: year, month: _month, day: _day} = data, opts) do
case Keyword.fetch(opts, :pivot_year) do
{:ok, pivot_year} ->
current_century = div(DateTime.utc_now(Datix.calendar(opts)).year, 100)
year =
cond do
year < 0 -> year
year <= pivot_year -> current_century * 100 + year
true -> (current_century - 1) * 100 + year
end
data
|> Map.put(:year, year)
|> Map.delete(:year_2_digit)
|> new(opts)
:error ->
{:error, %OptionError{reason: :missing, option: :pivot_year}}
end
end
def new(data, opts), do: data |> Datix.assume(Date) |> new(opts)
defp validate(date, data) when is_map(data) do
validate(
date,
data
|> Map.drop([
:am_pm,
:day,
:hour,
:hour_12,
:microsecond,
:minute,
:month,
:second,
:year,
:zone_abbr,
:zone_offset
])
|> Enum.to_list()
)
end
defp validate(date, []), do: {:ok, date}
defp validate(date, [{:day_of_week, day_of_week} | rest]) do
case Date.day_of_week(date) do
^day_of_week -> validate(date, rest)
_day_of_week -> {:error, %ValidationError{reason: :invalid_date, module: __MODULE__}}
end
end
defp validate(date, [{:day_of_year, day_of_jear} | rest]) do
case Date.day_of_year(date) do
^day_of_jear -> validate(date, rest)
_day_of_jear -> {:error, %ValidationError{reason: :invalid_date, module: __MODULE__}}
end
end
defp validate(date, [{:quarter, quarter} | rest]) do
case Date.quarter_of_year(date) do
^quarter -> validate(date, rest)
_quarter -> {:error, %ValidationError{reason: :invalid_date, module: __MODULE__}}
end
end
end