defmodule Timex.Format.DateTime.Formatters.Relative do @moduledoc """ Relative time, based on Moment.js 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 22 hours | 2 hours ago ... 22 hours ago | 22 to 36 hours | a day ago | 36 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, <>) 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 < @day -> Translator.translate_plural( locale, "relative_time", "in %{count} hour", "in %{count} hours", div(diff, @hour) ) diff >= @day && diff < @day * 2 -> Translator.translate( locale, "relative_time", "tomorrow" ) diff >= @day * 2 && 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 > @day * -1 -> Translator.translate_plural( locale, "relative_time", "%{count} hour ago", "%{count} hours ago", div(diff * -1, @hour) ) diff <= @day && diff > @day * 2 * -1 -> Translator.translate( locale, "relative_time", "yesterday" ) diff <= @day * 2 && 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, <>) 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, <>) 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