ferne/lib/ferne/relative_formatter.ex

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