354 lines
10 KiB
Elixir
354 lines
10 KiB
Elixir
defmodule Ferne.RelativeFormatter do
|
|
@moduledoc """
|
|
Relative time, based on Timex' Relative formatter.
|
|
This file is adapted from Timex, Copyright (c) 2016 Paul Schoenfelder, licensed under the MIT license.
|
|
|
|
Uses localized strings.
|
|
|
|
The format string should contain {relative}, which is where the phrase will be injected.
|
|
|
|
| Range | Sample Output
|
|
---------------------------------------------------------------------
|
|
| 0 seconds | now
|
|
| 1 to 45 seconds | a few seconds ago
|
|
| 45 to 90 seconds | a minute ago
|
|
| 90 seconds to 45 minutes | 2 minutes ago ... 45 minutes ago
|
|
| 45 to 90 minutes | an hour ago
|
|
| 90 minutes to 48 hours | 2 hours ago ... 48 hours ago
|
|
| 48 hours to 25 days | 2 days ago ... 25 days ago
|
|
| 25 to 45 days | a month ago
|
|
| 45 to 345 days | 2 months ago ... 11 months ago
|
|
| 345 to 545 days (1.5 years) | a year ago
|
|
| 546 days+ | 2 years ago ... 20 years ago
|
|
"""
|
|
use Timex.Format.DateTime.Formatter
|
|
use Combine
|
|
alias Timex.Format.FormatError
|
|
alias Timex.{Types, Translator}
|
|
|
|
@spec tokenize(String.t()) :: {:ok, [Directive.t()]} | {:error, term}
|
|
def tokenize(format_string) do
|
|
case Combine.parse(format_string, relative_parser()) do
|
|
results when is_list(results) ->
|
|
directives = results |> List.flatten() |> Enum.filter(fn x -> x !== nil end)
|
|
|
|
case Enum.any?(directives, fn %Directive{type: type} -> type != :literal end) do
|
|
false -> {:error, "Invalid format string, must contain at least one directive."}
|
|
true -> {:ok, directives}
|
|
end
|
|
|
|
{:error, _} = err ->
|
|
err
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Formats a date/time as a relative time formatted string
|
|
|
|
## Examples
|
|
|
|
iex> #{__MODULE__}.format(Timex.shift(Timex.now, minutes: -1), "{relative}")
|
|
{:ok, "1 minute ago"}
|
|
"""
|
|
@spec format(Types.calendar_types(), String.t()) :: {:ok, String.t()} | {:error, term}
|
|
def format(date, format_string), do: lformat(date, format_string, Translator.current_locale())
|
|
|
|
@spec format!(Types.calendar_types(), String.t()) :: String.t() | no_return
|
|
def format!(date, format_string), do: lformat!(date, format_string, Translator.current_locale())
|
|
|
|
@spec lformat(Types.calendar_types(), String.t(), String.t()) ::
|
|
{:ok, String.t()} | {:error, term}
|
|
def lformat(date, format_string, locale) do
|
|
case tokenize(format_string) do
|
|
{:ok, []} ->
|
|
{:error, "There were no formatting directives in the provided string."}
|
|
|
|
{:ok, dirs} when is_list(dirs) ->
|
|
do_format(
|
|
locale,
|
|
Timex.to_naive_datetime(date),
|
|
Timex.Protocol.NaiveDateTime.now(),
|
|
dirs,
|
|
<<>>
|
|
)
|
|
|
|
{:error, reason} ->
|
|
{:error, {:format, reason}}
|
|
end
|
|
end
|
|
|
|
@spec lformat!(Types.calendar_types(), String.t(), String.t()) :: String.t() | no_return
|
|
def lformat!(date, format_string, locale) do
|
|
case lformat(date, format_string, locale) do
|
|
{:ok, result} -> result
|
|
{:error, reason} -> raise FormatError, message: reason
|
|
end
|
|
end
|
|
|
|
def relative_to(date, relative_to, format_string) do
|
|
relative_to(date, relative_to, format_string, Translator.current_locale())
|
|
end
|
|
|
|
def relative_to(date, relative_to, format_string, locale) do
|
|
case tokenize(format_string) do
|
|
{:ok, []} ->
|
|
{:error, "There were no formatting directives in the provided string."}
|
|
|
|
{:ok, dirs} when is_list(dirs) ->
|
|
do_format(
|
|
locale,
|
|
Timex.to_naive_datetime(date),
|
|
Timex.to_naive_datetime(relative_to),
|
|
dirs,
|
|
<<>>
|
|
)
|
|
|
|
{:error, reason} ->
|
|
{:error, {:format, reason}}
|
|
end
|
|
end
|
|
|
|
@minute 60
|
|
@hour @minute * 60
|
|
@day @hour * 24
|
|
@month @day * 30
|
|
@year @month * 12
|
|
|
|
defp do_format(_locale, _date, _relative, [], result), do: {:ok, result}
|
|
|
|
defp do_format(locale, date, relative, [%Directive{type: :literal, value: char} | dirs], result)
|
|
when is_binary(char) do
|
|
do_format(locale, date, relative, dirs, <<result::binary, char::binary>>)
|
|
end
|
|
|
|
defp do_format(locale, date, relative, [%Directive{type: :relative} | dirs], result) do
|
|
diff = Timex.diff(date, relative, :seconds)
|
|
|
|
phrase =
|
|
cond do
|
|
# future
|
|
diff == 0 ->
|
|
Translator.translate(locale, "relative_time", "now")
|
|
|
|
diff > 0 && diff <= 45 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} second",
|
|
"in %{count} seconds",
|
|
diff
|
|
)
|
|
|
|
diff > 45 && diff < @minute * 2 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} minute",
|
|
"in %{count} minutes",
|
|
1
|
|
)
|
|
|
|
diff >= @minute * 2 && diff < @hour ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} minute",
|
|
"in %{count} minutes",
|
|
div(diff, @minute)
|
|
)
|
|
|
|
diff >= @hour && diff < @hour * 2 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} hour",
|
|
"in %{count} hours",
|
|
1
|
|
)
|
|
|
|
diff >= @hour * 2 && diff < @hour * 48 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} hour",
|
|
"in %{count} hours",
|
|
div(diff, @hour)
|
|
)
|
|
|
|
diff >= @hour * 48 && diff < @month ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} day",
|
|
"in %{count} days",
|
|
div(diff, @day)
|
|
)
|
|
|
|
diff >= @month && diff < @month * 2 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} month",
|
|
"in %{count} months",
|
|
1
|
|
)
|
|
|
|
diff >= @month * 2 && diff < @year ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} month",
|
|
"in %{count} months",
|
|
div(diff, @month)
|
|
)
|
|
|
|
diff >= @year && diff < @year * 2 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} year",
|
|
"in %{count} years",
|
|
1
|
|
)
|
|
|
|
diff >= @year * 2 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"in %{count} year",
|
|
"in %{count} years",
|
|
div(diff, @year)
|
|
)
|
|
|
|
# past
|
|
diff < 0 && diff >= -45 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} second ago",
|
|
"%{count} seconds ago",
|
|
diff * -1
|
|
)
|
|
|
|
diff < -45 && diff > @minute * 2 * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} minute ago",
|
|
"%{count} minutes ago",
|
|
1
|
|
)
|
|
|
|
diff <= @minute * 2 && diff > @hour * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} minute ago",
|
|
"%{count} minutes ago",
|
|
div(diff * -1, @minute)
|
|
)
|
|
|
|
diff <= @hour && diff > @hour * 2 * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} hour ago",
|
|
"%{count} hours ago",
|
|
1
|
|
)
|
|
|
|
diff <= @hour * 2 && diff > @hour * 48 * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} hour ago",
|
|
"%{count} hours ago",
|
|
div(diff * -1, @hour)
|
|
)
|
|
|
|
diff <= @hour * 48 && diff > @month * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} day ago",
|
|
"%{count} days ago",
|
|
div(diff * -1, @day)
|
|
)
|
|
|
|
diff <= @month && diff > @month * 2 * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} month ago",
|
|
"%{count} months ago",
|
|
1
|
|
)
|
|
|
|
diff <= @month * 2 && diff > @year * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} month ago",
|
|
"%{count} months ago",
|
|
div(diff * -1, @month)
|
|
)
|
|
|
|
diff <= @year && diff > @year * 2 * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} year ago",
|
|
"%{count} years ago",
|
|
1
|
|
)
|
|
|
|
diff <= @year * 2 * -1 ->
|
|
Translator.translate_plural(
|
|
locale,
|
|
"relative_time",
|
|
"%{count} year ago",
|
|
"%{count} years ago",
|
|
div(diff * -1, @year)
|
|
)
|
|
end
|
|
|
|
do_format(locale, date, relative, dirs, <<result::binary, phrase::binary>>)
|
|
end
|
|
|
|
defp do_format(
|
|
locale,
|
|
date,
|
|
relative,
|
|
[%Directive{type: type, modifiers: mods, flags: flags, width: width} | dirs],
|
|
result
|
|
) do
|
|
case format_token(locale, type, date, mods, flags, width) do
|
|
{:error, _} = err -> err
|
|
formatted -> do_format(locale, date, relative, dirs, <<result::binary, formatted::binary>>)
|
|
end
|
|
end
|
|
|
|
# Token parser
|
|
defp relative_parser do
|
|
many1(
|
|
choice([
|
|
between(char(?{), map(one_of(word(), ["relative"]), &map_directive/1), char(?})),
|
|
map(none_of(char(), ["{", "}"]), &map_literal/1)
|
|
])
|
|
)
|
|
end
|
|
|
|
# Gets/builds the Directives for a given token
|
|
defp map_directive("relative"),
|
|
do: %Directive{:type => :relative, :value => "relative"}
|
|
|
|
# Generates directives for literal characters
|
|
defp map_literal([]), do: nil
|
|
|
|
defp map_literal(literals)
|
|
when is_list(literals),
|
|
do: Enum.map(literals, &map_literal/1)
|
|
|
|
defp map_literal(literal), do: %Directive{type: :literal, value: literal, parser: char(literal)}
|
|
end
|