defmodule RelativeDateTime do @moduledoc """ Parse a relative datetime string and return a datetime. """ import NimbleParsec whitespace = string(" ") now = string("now") |> unwrap_and_tag(:now) ago = string("ago") |> replace(-1) |> unwrap_and_tag(:multiplier) number = integer(min: 1) |> unwrap_and_tag(:amount) unit = choice([ string("second") |> replace(:seconds), string("minute") |> replace(:minutes), string("hour") |> replace(:hours), string("day") |> replace(:days), string("week") |> replace(:weeks), string("month") |> replace(:months), string("year") |> replace(:years) ]) |> ignore(string("s")) |> unwrap_and_tag(:unit) hour = choice([ ignore(string("0")) |> integer(1), ignore(string("1")) |> integer(1) |> map({Kernel, :+, [10]}), ignore(string("2")) |> ascii_char([?0..?3]) |> map({Kernel, :+, [20 - ?0]}) ]) |> map({:add_unit, [:hours]}) |> unwrap_and_tag(:relative_datetime) minute = integer(2) |> map({:add_unit, [:minutes]}) |> unwrap_and_tag(:relative_datetime) time_of_day = hour |> ignore(string(":")) |> concat(minute) relative_day_pronoun = choice([ string("today") |> replace(amount: 0, unit: :days), string("yesterday") |> replace(amount: -1, unit: :days), string("tomorrow") |> replace(amount: 1, unit: :days) ]) |> unwrap_and_tag(:relative_datetime) relative_datetime_suffix = optional(ignore(whitespace) |> concat(ago)) relative_datetime_pattern = number |> ignore(whitespace) |> concat(unit) |> tag(:relative_datetime) relative_datetime_joiner = choice([ ignore(whitespace) |> ignore(string("and")) |> ignore(whitespace), ignore(optional(whitespace)) |> ignore(string(",")) |> ignore(whitespace), ignore(whitespace) ]) relative_datetime = relative_datetime_pattern |> optional(relative_datetime_joiner |> concat(relative_datetime_pattern)) time_of_day_and_relative_day_pronoun = time_of_day |> ignore(whitespace) |> concat(relative_day_pronoun) root = choice([ now, relative_day_pronoun, time_of_day_and_relative_day_pronoun, parsec({DateTimeParser.Combinators, :parse_datetime}), parsec({DateTimeParser.Combinators, :parse_datetime_us}), relative_datetime |> concat(relative_datetime_suffix) ]) defparsec(:parse_relative_datetime, root) @doc """ Parses a given relative datetime string and returns a datetime. iex> RelativeDateTime.parse("3 days ago", ~U[2018-01-01 00:00:00Z]) {:ok, ~U[2017-12-29 00:00:00Z]} """ @spec parse(String.t(), DateTime.t()) :: {:ok, DateTime.t()} | :error def parse(relative, datetime) do case parse_relative_datetime(relative) do {:ok, [now: "now"], _, _, _, _} -> {:ok, datetime} {:ok, [ {:year, year}, {:month, month}, {:day, day}, {:hour, hour}, {:minute, minute}, {:second, second} | _ ] = args, "", _, _, _} -> {:ok, Date.new!("#{year}" |> String.to_integer(), month, day) |> DateTime.new!(Time.new!(hour, minute, second, microseconds(args)))} {:ok, [year: year, month: month, day: day], "", _, _, _} -> {:ok, Date.new!("#{year}" |> String.to_integer(), month, day) |> DateTime.new!(~T[00:00:00])} {:ok, args, "", _, _, _} -> {:ok, apply_relative_datetime(datetime, args)} _ -> :error end end # defp apply_relative_datetime(datetime, args) do {multiplier, args} = Keyword.pop(args, :multiplier, 1) for {:relative_datetime, [amount: amount, unit: unit]} <- args, reduce: datetime do curr_datetime -> Timex.shift(curr_datetime, [{unit, amount * multiplier}]) end end defp microseconds(time) do case Keyword.fetch(time, :microsecond) do :error -> {0, 0} {:ok, microsecond} -> accuracy = length(microsecond) multiplier = Integer.pow(10, 6 - accuracy) microsecond = microsecond |> :string.to_integer() |> elem(0) |> Kernel.*(multiplier) {microsecond, accuracy} end end defp add_unit(amount, unit) do [amount: amount, unit: unit] end end