# Copyright(c) 2015-2023 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
alias Antikythera.MilliSecondsInGregorian
@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` or wrap valid `Antikythera.Time.t`,
leveraging `recursive_new?` option of `Croma.Struct`.
Only `Antikythera.IsoTimestamp.t` can be converted.
iex> {:ok, time} = #{__MODULE__}.new("2015-01-23T23:50:07Z")
{:ok, {#{__MODULE__}, {2015, 1, 23}, {23, 50, 7}, 0}}
iex> #{__MODULE__}.new(time)
{:ok, {#{__MODULE__}, {2015, 1, 23}, {23, 50, 7}, 0}}
iex> #{__MODULE__}.new("2015-01-23T23:50:07") |> Croma.Result.error?()
true
iex> #{__MODULE__}.new(nil) |> Croma.Result.error?()
true
"""
defun new(t | IsoTimestamp.t()) :: R.t(t) do
s when is_binary(s) -> from_iso_timestamp(s)
t -> R.wrap_if_valid(t, __MODULE__)
end
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}
defun now() :: t do
from_epoch_milliseconds(System.system_time(:millisecond))
end
defun to_iso_timestamp({__MODULE__, {y, mon, d}, {h, min, s}, millis} :: t) :: IsoTimestamp.t() do
import Antikythera.StringFormat
<<Integer.to_string(y)::binary-size(4), "-", pad2(mon)::binary-size(2), "-",
pad2(d)::binary-size(2), "T", pad2(h)::binary-size(2), ":", pad2(min)::binary-size(2), ":",
pad2(s)::binary-size(2), ".", pad3(millis)::binary-size(3), "+00:00">>
end
defun to_iso_basic({__MODULE__, {y, mon, d}, {h, min, s}, _} :: t) :: IsoBasic.t() do
import Antikythera.StringFormat
<<Integer.to_string(y)::binary-size(4), pad2(mon)::binary-size(2), pad2(d)::binary-size(2),
"T", pad2(h)::binary-size(2), pad2(min)::binary-size(2), pad2(s)::binary-size(2), "Z">>
end
defun from_iso_timestamp(s :: v[String.t()]) :: R.t(t) do
R.try(fn ->
<<year::binary-size(4), "-", month::binary-size(2), "-", day::binary-size(2), "T",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2),
rest1::binary>> = 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 ->
<<year::binary-size(4), month::binary-size(2), day::binary-size(2), "T",
hour::binary-size(2), minute::binary-size(2), second::binary-size(2), rest::binary>> = 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)>> -> convert_to_minutes(h, m)
<<"+", h::binary-size(2), m::binary-size(2)>> -> convert_to_minutes(h, m)
<<"-", h::binary-size(2), ":", m::binary-size(2)>> -> -convert_to_minutes(h, m)
<<"-", h::binary-size(2), m::binary-size(2)>> -> -convert_to_minutes(h, m)
"Z" -> 0
end
end
defp convert_to_minutes(hour, minute) do
String.to_integer(hour) * 60 + String.to_integer(minute)
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) - MilliSecondsInGregorian.time_epoch_offset_milliseconds()
end
defun from_epoch_milliseconds(milliseconds :: v[MilliSecondsInGregorian.t()]) :: t do
from_gregorian_milliseconds(
milliseconds + MilliSecondsInGregorian.time_epoch_offset_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
# safe to use since it always read input as UTC
case :httpd_util.convert_request_date(String.to_charlist(s)) do
{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