defmodule Timex.Parse.DateTime.Parser do @moduledoc """ This is the base plugin behavior for all Timex date/time string parsers. """ import Combine.Parsers.Base, only: [eof: 0, map: 2, pipe: 2] alias Timex.{Timezone, TimezoneInfo, AmbiguousDateTime, AmbiguousTimezoneInfo} alias Timex.Parse.ParseError alias Timex.Parse.DateTime.Tokenizers.{Directive, Default, Strftime} @doc """ Parses a date/time string using the default parser. ## Examples iex> use Timex ...> {:ok, dt} = #{__MODULE__}.parse("2014-07-29T00:20:41.196Z", "{ISO:Extended:Z}") ...> dt.year 2014 iex> dt.month 7 iex> dt.day 29 iex> dt.time_zone "Etc/UTC" """ @spec parse(binary, binary) :: {:ok, DateTime.t | NaiveDateTime.t} | {:error, term} def parse(date_string, format_string) when is_binary(date_string) and is_binary(format_string), do: parse(date_string, format_string, Default) def parse(_, _), do: {:error, :badarg} @doc """ Parses a date/time string using the provided tokenizer. Tokenizers must implement the `Timex.Parse.DateTime.Tokenizer` behaviour. ## Examples iex> use Timex ...> {:ok, dt} = #{__MODULE__}.parse("2014-07-29T00:30:41.196-02:00", "{ISO:Extended}", Timex.Parse.DateTime.Tokenizers.Default) ...> dt.year 2014 iex> dt.month 7 iex> dt.day 29 iex> dt.time_zone "Etc/GMT+2" """ @spec parse(binary, binary, atom) :: {:ok, DateTime.t | NaiveDateTime.t} | {:error, term} def parse(date_string, format_string, tokenizer) when is_binary(date_string) and is_binary(format_string) do try do {:ok, parse!(date_string, format_string, tokenizer)} catch _type, %ParseError{:message => msg} -> {:error, msg} _type, %{:message => msg} -> {:error, msg} _type, reason -> {:error, reason} end end def parse(_, _, _), do: {:error, :badarg} @doc """ Same as `parse/2` and `parse/3`, but raises on error. """ @spec parse!(String.t, String.t, atom | nil) :: DateTime.t | NaiveDateTime.t | no_return def parse!(date_string, format_string, tokenizer \\ Default) def parse!(date_string, format_string, :strftime), do: parse!(date_string, format_string, Strftime) def parse!(date_string, format_string, tokenizer) when is_binary(date_string) and is_binary(format_string) and is_atom(tokenizer) do case tokenizer.tokenize(format_string) do {:error, err} when is_binary(err) -> raise ParseError, message: err {:error, err} -> raise ParseError, message: "#{inspect err}" {:ok, []} -> raise ParseError, message: "There were no parsing directives in the provided format string." {:ok, directives} -> case date_string do "" -> raise ParseError, message: "Input datetime string cannot be empty!" _ -> datetime = case do_parse(date_string, directives, tokenizer) do {:ok, %DateTime{time_zone: nil} = dt} -> Timex.to_naive_datetime(dt) {:ok, dt} -> dt {:error, reason} when is_binary(reason) -> raise ParseError, message: reason {:error, reason} -> raise ParseError, message: "#{inspect reason}" end if :calendar.valid_date(datetime.year, datetime.month, datetime.day) do datetime else raise ParseError, message: "#{inspect datetime} is an invalid date!" end end end end # Special case iso8601/rfc3339 for performance defp do_parse(str, [%Directive{:type => type}], _tokenizer) when type in [:iso_8601_extended, :iso_8601_extended_z, :rfc_3339, :rfc_3339z] do case Combine.parse(str, Timex.Parse.DateTime.Parsers.ISO8601Extended.parse) do {:error, _} = err -> err [parts] when is_list(parts) -> case Enum.into(parts, %{}) do %{year4: y, month: m, day: d, hour24: h, zname: tzname} = mapped -> mm = Map.get(mapped, :min, 0) ss = Map.get(mapped, :sec, 0) us = Map.get(mapped, :sec_fractional, {0,0}) tz = Timezone.get(tzname, {{y,m,d},{h,mm,ss}}) {:ok, %DateTime{ year: y, month: m, day: d, hour: h, minute: mm, second: ss, microsecond: us, time_zone: tz.full_name, zone_abbr: tz.abbreviation, utc_offset: tz.offset_utc, std_offset: tz.offset_std}} %{year4: y, month: m, day: d, hour24: h} = mapped -> mm = Map.get(mapped, :min, 0) ss = Map.get(mapped, :sec, 0) us = Map.get(mapped, :sec_fractional, {0,0}) {:ok, %NaiveDateTime{ year: y, month: m, day: d, hour: h, minute: mm, second: ss, microsecond: us}} end end end defp do_parse(str, directives, tokenizer) do parsers = directives |> Stream.map(fn %Directive{weight: weight, parser: parser} -> map(parser, &({&1, weight})) end) |> Stream.filter(fn nil -> false; _ -> true end) |> Enum.reverse case Combine.parse(str, pipe([eof()|parsers] |> Enum.reverse, &(&1))) do [results] when is_list(results) -> results |> extract_parse_results |> Stream.with_index |> Enum.sort_by(fn # If :force_utc exists, make sure it is applied last {{{:force_utc, true}, _}, _} -> 9999 # Timezones must always be applied after other date/time tokens -> {{{tz, _}, _}, _} when tz in [:zname, :zoffs, :zoffs_colon, :zoffs_sec] -> 9998 # If no weight is set, use the index as its weight {{{_token, _value}, 0}, i} -> i # Use the directive weight {{{_token, _value}, weight}, _} -> weight end) |> Stream.flat_map(fn {{token, _}, _} -> [token] end) |> Enum.filter(&Kernel.is_tuple/1) |> apply_directives(tokenizer) {:error, _} = err -> err end end defp extract_parse_results(parse_results), do: extract_parse_results(parse_results, []) defp extract_parse_results([], acc), do: Enum.reverse(acc) defp extract_parse_results([{tokens, weight}|rest], acc) when is_list(tokens) do extracted = extract_parse_results(tokens) |> Enum.map(fn {{token, value}, _weight} -> {{token, value}, weight} end) |> Enum.reverse extract_parse_results(rest, extracted ++ acc) end defp extract_parse_results([{{token, value}, weight}|rest], acc) when is_atom(token) do extract_parse_results(rest, [{{token, value}, weight}|acc]) end defp extract_parse_results([{token, value}|rest], acc) when is_atom(token) do extract_parse_results(rest, [{{token, value}, 0}|acc]) end defp extract_parse_results([[{token, value}]|rest], acc) when is_atom(token) do extract_parse_results(rest, [{{token, value}, 0}|acc]) end defp extract_parse_results([h|rest], acc) when is_list(h) do extracted = Enum.reverse(extract_parse_results(h)) extract_parse_results(rest, extracted ++ acc) end defp extract_parse_results([_|rest], acc) do extract_parse_results(rest, acc) end # Constructs a DateTime from the parsed tokens defp apply_directives([], _), do: {:ok, Timex.DateTime.Helpers.empty()} defp apply_directives(tokens, tokenizer), do: apply_directives(tokens, Timex.DateTime.Helpers.empty(), tokenizer) defp apply_directives([], date, _), do: {:ok, date} defp apply_directives([{token, value}|tokens], date, tokenizer) do case update_date(date, token, value, tokenizer) do {:error, _} = error -> error updated -> apply_directives(tokens, updated, tokenizer) end end # Given a date, a token, and the value for that token, update the # date according to the rules for that token and the provided value defp update_date(%AmbiguousDateTime{} = adt, token, value, tokenizer) when is_atom(token) do bd = update_date(adt.before, token, value, tokenizer) ad = update_date(adt.after, token, value, tokenizer) %{adt | :before => bd, :after => ad} end defp update_date(%{year: year, hour: hh} = date, token, value, tokenizer) when is_atom(token) do case token do # Formats clock when clock in [:kitchen, :strftime_iso_kitchen] -> date = cond do date == Timex.DateTime.Helpers.empty() -> {{y,m,d},_} = :calendar.universal_time() %{date | :year => y, :month => m, :day => d} true -> date end case apply_directives(value, date, tokenizer) do {:error, _} = err -> err {:ok, date} when clock == :kitchen -> %{date | :second => 0, :microsecond => {0,0}} {:ok, date} -> %{date | :microsecond => {0,0}} end # Years :century -> century = Timex.century(%{date | :year => year}) year_shifted = year + ((value - century) * 100) %{date | :year => year_shifted} y when y in [:year2, :iso_year2] -> {{y,_,_},_} = :calendar.universal_time() current_century = Timex.century(y) year_shifted = value + ((current_century - 1) * 100) %{date | :year => year_shifted} y when y in [:year4, :iso_year4] -> # Special case for UNIX format dates, where the year is parsed after the timezone, # so we must lookup the timezone again to ensure it's properly set case date do %{time_zone: tzname} when is_nil(tzname) == false -> seconds_from_zeroyear = Timex.to_gregorian_seconds(date) date = to_datetime(date) case Timezone.resolve(tzname, seconds_from_zeroyear) do %TimezoneInfo{} = tz -> %{date | :year => value, :time_zone => tz.full_name, :zone_abbr => tz.abbreviation, :utc_offset => tz.offset_utc, :std_offset => tz.offset_std} %AmbiguousTimezoneInfo{before: b, after: a} -> bd = %{date | :year => value, :time_zone => b.full_name, :zone_abbr => b.abbreviation, :utc_offset => b.offset_utc, :std_offset => b.offset_std} ad = %{date | :year => value, :time_zone => a.full_name, :zone_abbr => a.abbreviation, :utc_offset => a.offset_utc, :std_offset => a.offset_std} %AmbiguousDateTime{:before => bd, :after => ad} end _ -> %{date | :year => value} end # Months :month -> %{date | :month => value} month when month in [:mshort, :mfull] -> %{date | :month => Timex.month_to_num(value)} # Days :day -> %{date | :day => value} :oday when is_integer(value) and value >= 0 -> Timex.from_iso_day(value, date) :wday_mon -> current_day = Timex.weekday(date) cond do current_day == value -> date current_day > value -> Timex.shift(date, days: current_day - value) current_day < value -> Timex.shift(date, days: value - current_day) end :wday_sun -> current_day = Timex.weekday(date) - 1 cond do current_day == value -> date current_day > value -> Timex.shift(date, days: current_day - value) current_day < value -> Timex.shift(date, days: value - current_day) end day when day in [:wdshort, :wdfull] -> %{date | :day => Timex.day_to_num(value)} # Weeks :iso_weeknum -> {year, _, weekday} = Timex.iso_triplet(date) %Date{year: y, month: m, day: d} = Timex.from_iso_triplet({year, value, weekday}) %{date | :year => y, :month => m, :day => d} week_num when week_num in [:week_mon, :week_sun] -> reset = %{date | :month => 1, :day => 1} reset |> Timex.shift(weeks: value) :weekday -> date |> Timex.shift(days: value - 1) # Hours hour when hour in [:hour24, :hour12] -> %{date | :hour => value} :min -> %{date | :minute => value} :sec -> %{date | :second => value} :sec_fractional -> case value do "" -> date n when is_number(n) -> %{date | :microsecond => Timex.DateTime.Helpers.construct_microseconds(n)} {_n, _precision} = us -> %{date | :microsecond => us} end :us -> %{date | :microsecond => Timex.DateTime.Helpers.construct_microseconds(value)} :ms -> %{date | :microsecond => Timex.DateTime.Helpers.construct_microseconds(value*1_000)} :sec_epoch -> DateTime.from_unix!(value) am_pm when am_pm in [:am, :AM] -> cond do hh == 24 -> %{date | :hour => 0} hh == 12 and (String.downcase(value) == "am") -> %{date | :hour => 0} hh in (1..11) and String.downcase(value) == "pm" -> %{date | :hour => hh + 12} true -> date end # Timezones :zoffs -> date = to_datetime(date) case value do <> -> hour = <> hours = String.to_integer(hour) minutes = 0 {gmt_sign, total_offset} = case sign do ?- -> {?+, -1 * ((hours*60*60) + (minutes*60))} ?+ -> {?-, ((hours*60*60) + (minutes*60))} end case hours do 0 -> %{date | :time_zone => "Etc/GMT+0", :zone_abbr => "GMT", :utc_offset => total_offset, :std_offset => 0} h when h < 10 -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, h2::utf8>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} _ -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} end <> -> hour = <> hours = String.to_integer(hour) minute = <> minutes = String.to_integer(minute) {gmt_sign, total_offset} = case sign do ?- -> {?+, -1 * ((hours*60*60) + (minutes*60))} ?+ -> {?-, ((hours*60*60) + (minutes*60))} end case {hours, minutes} do {0, 0} -> %{date | :time_zone => "Etc/GMT+0", :zone_abbr => "GMT", :utc_offset => total_offset, :std_offset => 0} {h, 0} when h < 10 -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, h2::utf8>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} {_, 0} -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} _ -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary, ?:, minute::binary>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} end _ -> {:error, "invalid offset: #{inspect value}"} end :zname -> seconds_from_zeroyear = Timex.to_gregorian_seconds(date) case Timezone.name_of(value) do {:error, _} = err -> err tzname -> date = to_datetime(date) case Timezone.resolve(tzname, seconds_from_zeroyear) do %TimezoneInfo{} = tz -> %{date | :time_zone => tz.full_name, :zone_abbr => tz.abbreviation, :utc_offset => tz.offset_utc, :std_offset => tz.offset_std} %AmbiguousTimezoneInfo{before: b, after: a} -> bd = %{date | :time_zone => b.full_name, :zone_abbr => b.abbreviation, :utc_offset => b.offset_utc, :std_offset => b.offset_std} ad = %{date | :time_zone => a.full_name, :zone_abbr => a.abbreviation, :utc_offset => a.offset_utc, :std_offset => a.offset_std} %AmbiguousDateTime{:before => bd, :after => ad} end end :zoffs_colon -> date = to_datetime(date) case value do <> -> hour = <> hours = String.to_integer(hour) minute = <> minutes = String.to_integer(minute) {gmt_sign, total_offset} = case sign do ?- -> {?+, -1 * ((hours*60*60) + (minutes*60))} ?+ -> {?-, ((hours*60*60) + (minutes*60))} end case {hours, minutes} do {0, 0} -> %{date | :time_zone => "Etc/GMT+0", :zone_abbr => "GMT", :utc_offset => total_offset, :std_offset => 0} {h, 0} when h < 10 -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, h2::utf8>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} {_, 0} -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} _ -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary, ?:, minute::binary>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} end _ -> {:error, "invalid offset: #{inspect value}"} end :zoffs_sec -> date = to_datetime(date) case value do <> -> hour = <> hours = String.to_integer(hour) minute = <> minutes = String.to_integer(minute) second = <> seconds = String.to_integer(second) {gmt_sign, total_offset} = case sign do ?- -> {?+, -1 * ((hours*60*60) + (minutes*60))} ?+ -> {?-, ((hours*60*60) + (minutes*60))} end case {hours, minutes, seconds} do {0, 0, 0} -> %{date | :time_zone => "Etc/GMT+0", :zone_abbr => "GMT", :utc_offset => total_offset, :std_offset => 0} {h, 0, 0} when h < 10 -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, h2::utf8>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} {_, 0, 0} -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} _ -> %{date | :time_zone => <<"Etc/GMT", gmt_sign::utf8, hour::binary, ?:, minute::binary, ?:, second::binary>>, :zone_abbr => <>, :utc_offset => total_offset, :std_offset => 0} end _ -> {:error, "invalid offset: #{inspect value}"} end :force_utc -> date = to_datetime(date) case date.time_zone do nil -> %{date | :time_zone => "Etc/UTC", :zone_abbr => "UTC", :utc_offset => 0, :std_offset => 0} _ -> Timezone.convert(date, "UTC") end :literal -> date :week_of_year -> shift_to_week_of_year(:mon, date, value) :week_of_year_sun -> shift_to_week_of_year(:sun, date, value) _ -> case tokenizer.apply(date, token, value) do {:ok, date} -> date {:error, _} = err -> err _ -> {:error, "Unrecognized token: #{token}"} end end end defp shift_to_week_of_year(:mon, %{year: y} = datetime, value) when is_integer(value) do shift = case :calendar.day_of_the_week({y, 1, 1}) do n when n < 5 -> # Week 1, seek backwards to beginning of week [days: -(7-(7-(n-1)))] n -> # Part of last year's week, seek forwards to beginning of week [days: (7-(7-(n-1))) - 1] end datetime = Timex.to_naive_datetime(datetime) do_shift_to_week_of_year(Timex.shift(%{datetime | month: 1, day: 1}, shift), value) end defp shift_to_week_of_year(:sun, %{year: y} = datetime, value) when is_integer(value) do n = :calendar.day_of_the_week({y, 1, 1}) shift = [days: -1 - (7-(7-(n-1)))] datetime = Timex.to_naive_datetime(datetime) do_shift_to_week_of_year(Timex.shift(%{datetime | month: 1, day: 1}, shift), value) end defp do_shift_to_week_of_year(%{year: y} = datetime, weeks) do # On leap years which start on Thursday, week numbers # are incremented by 1 from March thru the rest of the year shifted = Timex.shift(datetime, days: 7 * (weeks-1)) if :calendar.is_leap_year(y) do case :calendar.day_of_the_week({y,1,1}) do 4 -> case shifted do %{month: m} when m < 3 -> shifted _ -> Timex.shift(shifted, days: 7) end _n -> shifted end else shifted end end defp to_datetime(%DateTime{} = dt), do: dt defp to_datetime(%NaiveDateTime{year: y, month: m, day: d, hour: h, minute: mm, second: ss, microsecond: us}) do %DateTime{year: y, month: m, day: d, hour: h, minute: mm, second: ss, microsecond: us, time_zone: "Etc/UTC", zone_abbr: "UTC", utc_offset: 0, std_offset: 0} end end