# Copyright(c) 2015-2018 ACCESS CO., LTD. All rights reserved. use Croma defmodule Antikythera.Time do @moduledoc """ Data structure to represent date and time in milli-seconds resolution. Note that all values of `Antikythera.Time.t` are in UTC. `Poison.Encoder` protocol is implemented for `Antikythera.Time.t`, so that values of this type can be directly converted to `Antikythera.IsoTimestamp.t` on `Poison.encode/1`. iex> Poison.encode(%{time: {Antikythera.Time, {2017, 1, 1}, {0, 0, 0}, 0}}) {:ok, "{\"time\":\"2017-01-01T00:00:00.000+00:00\"}"} See also `new/1`. """ alias Croma.Result, as: R alias Antikythera.{IsoTimestamp, ImfFixdate} alias Antikythera.IsoTimestamp.Basic, as: IsoBasic @typep milliseconds :: 0..999 @type t :: {__MODULE__, :calendar.date, :calendar.time, milliseconds} defun valid?(v :: term) :: boolean do {__MODULE__, date, {h, m, s}, ms} -> :calendar.valid_date(date) and h in 0..23 and m in 0..59 and s in 0..59 and ms in 0..999 _ -> false end @doc """ Convert timestamps into `Antikythera.Time.t`, leveraging `recursive_new?` option of `Croma.Struct`. Only `Antikythera.IsoTimestamp.t` can be converted. """ defun new(s :: v[IsoTimestamp.t]) :: R.t(t), do: from_iso_timestamp(s) defun truncate_to_day( {__MODULE__, date, {_ , _ , _ }, _} :: t) :: t, do: {__MODULE__, date, {0 , 0 , 0 }, 0} defun truncate_to_hour( {__MODULE__, date, {hour, _ , _ }, _} :: t) :: t, do: {__MODULE__, date, {hour, 0 , 0 }, 0} defun truncate_to_minute({__MODULE__, date, {hour, minute, _ }, _} :: t) :: t, do: {__MODULE__, date, {hour, minute, 0 }, 0} defun truncate_to_second({__MODULE__, date, {hour, minute, second}, _} :: t) :: t, do: {__MODULE__, date, {hour, minute, second}, 0} @time_epoch_offset_milliseconds (:calendar.datetime_to_gregorian_seconds({{1970, 1, 1}, {0, 0, 0}}) * 1000) defun now() :: t do from_epoch_milliseconds(System.system_time(:milliseconds)) end defun to_iso_timestamp({__MODULE__, {y, mon, d}, {h, min, s}, millis} :: t) :: IsoTimestamp.t do import Antikythera.StringFormat "#{y}-#{pad2(mon)}-#{pad2(d)}T#{pad2(h)}:#{pad2(min)}:#{pad2(s)}.#{pad3(millis)}+00:00" end defun to_iso_basic({__MODULE__, {y, mon, d}, {h, min, s}, _} :: t) :: IsoBasic.t do import Antikythera.StringFormat "#{y}#{pad2(mon)}#{pad2(d)}T#{pad2(h)}#{pad2(min)}#{pad2(s)}Z" end defun from_iso_timestamp(s :: v[String.t]) :: R.t(t) do R.try(fn -> <> = s {millis, rest2} = extract_millis(rest1) time = { __MODULE__, {String.to_integer(year), String.to_integer(month ), String.to_integer(day )}, {String.to_integer(hour), String.to_integer(minute), String.to_integer(second)}, millis, } adjust_by_timezone_offset(time, rest2) end) |> R.bind(&R.wrap_if_valid(&1, __MODULE__)) end R.define_bang_version_of(from_iso_timestamp: 1) defun from_iso_basic(s :: v[String.t]) :: R.t(t) do R.try(fn -> <> = s time = { __MODULE__, {String.to_integer(year), String.to_integer(month ), String.to_integer(day )}, {String.to_integer(hour), String.to_integer(minute), String.to_integer(second)}, 0, } adjust_by_timezone_offset(time, rest) end) |> R.bind(&R.wrap_if_valid(&1, __MODULE__)) end R.define_bang_version_of(from_iso_basic: 1) defp extract_millis(str) do case str do <<".", millis :: binary-size(3), rest :: binary>> -> {String.to_integer(millis), rest} _ -> {0, str} end end defp adjust_by_timezone_offset(t, str) do case extract_timezone_offset_minutes(str) do 0 -> t offset_minutes -> shift_minutes(t, -offset_minutes) end end defp extract_timezone_offset_minutes(str) do case str do <<"+", h :: binary-size(2), ":", m :: binary-size(2)>> -> String.to_integer(h) * 60 + String.to_integer(m) <<"+", h :: binary-size(2), m :: binary-size(2)>> -> String.to_integer(h) * 60 + String.to_integer(m) <<"-", h :: binary-size(2), ":", m :: binary-size(2)>> -> -(String.to_integer(h) * 60 + String.to_integer(m)) <<"-", h :: binary-size(2), m :: binary-size(2)>> -> -(String.to_integer(h) * 60 + String.to_integer(m)) "Z" -> 0 end end defun shift_milliseconds(t :: v[t], milliseconds :: v[integer]) :: t do from_gregorian_milliseconds(to_gregorian_milliseconds(t) + milliseconds) end defun shift_seconds(t :: v[t], seconds :: v[integer]) :: t, do: shift_milliseconds(t, seconds * 1_000) defun shift_minutes(t :: v[t], minutes :: v[integer]) :: t, do: shift_milliseconds(t, minutes * 60 * 1_000) defun shift_hours( t :: v[t], hours :: v[integer]) :: t, do: shift_milliseconds(t, hours * 60 * 60 * 1_000) defun shift_days( t :: v[t], days :: v[integer]) :: t, do: shift_milliseconds(t, days * 24 * 60 * 60 * 1_000) defun diff_milliseconds(t1 :: v[t], t2 :: v[t]) :: integer do to_gregorian_milliseconds(t1) - to_gregorian_milliseconds(t2) end defun to_gregorian_milliseconds({__MODULE__, d, t, ms} :: t) :: integer do seconds = :calendar.datetime_to_gregorian_seconds({d, t}) seconds * 1000 + ms end defun from_gregorian_milliseconds(milliseconds :: v[integer]) :: t do m = rem(milliseconds, 1000) s = div(milliseconds, 1000) {date, time} = :calendar.gregorian_seconds_to_datetime(s) {__MODULE__, date, time, m} end defun to_epoch_milliseconds(t :: v[t]) :: integer do to_gregorian_milliseconds(t) - @time_epoch_offset_milliseconds end defun from_epoch_milliseconds(milliseconds :: v[integer]) :: t do milliseconds + @time_epoch_offset_milliseconds |> from_gregorian_milliseconds end @doc """ Returns date/time in IMF-fixdate format. The format is subset of Internet Message Format (RFC5322, formarly RFC822, RFC1123). Defined as 'preferred' format in RFC7231 and modern web servers or clients should send in this format. https://tools.ietf.org/html/rfc7231#section-7.1.1.1 """ defun to_http_date({__MODULE__, {y, mon, d} = date, {h, min, s}, _} :: t) :: ImfFixdate.t do # Not using `:httpd_util.rfc1123_date/2` since it reads inputs as localtime and forcibly perform UTC conversion import Antikythera.StringFormat day_str = :httpd_util.day(:calendar.day_of_the_week(date)) mon_str = :httpd_util.month(mon) "#{day_str}, #{pad2(d)} #{mon_str} #{y} #{pad2(h)}:#{pad2(min)}:#{pad2(s)} GMT" end @doc """ Parses HTTP-date formats into Antikythera.Time.t. Supports IMF-fixdate format, RFC 850 format and ANSI C's `asctime()` format for compatibility. Note: An HTTP-date value must represents time in UTC(GMT). Thus timezone string in the end must always be 'GMT'. Any other timezone string (such as 'JST') will actually be ignored and parsed as GMT. https://tools.ietf.org/html/rfc7231#section-7.1.1.1 """ defun from_http_date(s :: v[String.t]) :: R.t(t) do case :httpd_util.convert_request_date(String.to_charlist(s)) do # safe to use since it always read input as UTC {date, time} -> R.wrap_if_valid({__MODULE__, date, time, 0}, __MODULE__) :bad_date -> {:error, {:bad_date, s}} end end R.define_bang_version_of(from_http_date: 1) end