lib/cocktail/parser/i_calendar.ex

defmodule Cocktail.Parser.ICalendar do
  @moduledoc """
  Create schedules from iCalendar format.

  TODO: write long description
  """

  alias Cocktail.{Rule, Schedule}

  @time_regex ~r/^:?;?(?:TZID=(.+?):)?(.*?)(Z)?$/
  @datetime_format "{YYYY}{0M}{0D}T{h24}{m}{s}"
  @time_format "{h24}{m}{s}"

  @doc ~S"""
  Parses a string in iCalendar format into a `t:Cocktail.Schedule.t/0`.

  ## Examples

      iex> {:ok, schedule} = parse("DTSTART;TZID=America/Los_Angeles:20170810T160000\nRRULE:FREQ=DAILY;INTERVAL=2")
      ...> schedule
      #Cocktail.Schedule<Every 2 days>

      iex> {:ok, schedule} = parse("DTSTART;TZID=America/Los_Angeles:20170810T160000\nRRULE:FREQ=WEEKLY")
      ...> schedule
      #Cocktail.Schedule<Weekly>

      iex> {:ok, schedule} = parse("DTSTART;TZID=America/Los_Angeles:20170810T160000\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR")
      ...> schedule
      #Cocktail.Schedule<Weekly on Mondays, Wednesdays and Fridays>

      iex> {:ok, schedule} = parse("DTSTART;TZID=America/Los_Angeles:20170810T160000\nRRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;BYHOUR=10,12,14")
      ...> schedule
      #Cocktail.Schedule<Every 2 weeks on Mondays, Wednesdays and Fridays on the 10th, 12th and 14th hours of the day>
  """
  @spec parse(String.t()) :: {:ok, Schedule.t()} | {:error, term}
  def parse(i_calendar_string) when is_binary(i_calendar_string) do
    i_calendar_string
    |> String.trim()
    |> String.split("\n")
    |> Enum.map(&String.trim/1)
    |> parse_lines(Schedule.new(Timex.now()), 0)
  end

  @spec parse_lines([String.t()], Schedule.t(), non_neg_integer) :: {:ok, Schedule.t()} | {:error, term}
  defp parse_lines([], schedule, _), do: {:ok, schedule}

  defp parse_lines([line | rest], schedule, index) do
    with {:ok, schedule} <- parse_line(line, schedule, index) do
      parse_lines(rest, schedule, index + 1)
    end
  end

  @spec parse_line(String.t(), Schedule.t(), non_neg_integer) :: {:ok, Schedule.t()} | {:error, term}
  defp parse_line("DTSTART" <> time_string, schedule, index), do: parse_dtstart(time_string, schedule, index)
  defp parse_line("DTEND" <> time_string, schedule, index), do: parse_dtend(time_string, schedule, index)
  defp parse_line("RRULE:" <> options_string, schedule, index), do: parse_rrule(options_string, schedule, index)
  defp parse_line("RDATE" <> time_string, schedule, index), do: parse_rdate(time_string, schedule, index)
  defp parse_line("EXDATE" <> time_string, schedule, index), do: parse_exdate(time_string, schedule, index)
  defp parse_line(_, _, index), do: {:error, {:unknown_eventprop, index}}

  @spec parse_dtstart(String.t(), Schedule.t(), non_neg_integer) :: {:ok, Schedule.t()} | {:error, term}
  defp parse_dtstart(time_string, schedule, index) do
    case parse_datetime(time_string) do
      {:ok, datetime} -> {:ok, Schedule.set_start_time(schedule, datetime)}
      {:error, term} -> {:error, {term, index}}
    end
  end

  @spec parse_dtend(String.t(), Schedule.t(), non_neg_integer) :: {:ok, Schedule.t()} | {:error, term}
  defp parse_dtend(time_string, schedule, index) do
    case parse_datetime(time_string) do
      {:ok, datetime} -> {:ok, Schedule.set_end_time(schedule, datetime)}
      {:error, term} -> {:error, {term, index}}
    end
  end

  @spec parse_rrule(String.t(), Schedule.t(), non_neg_integer) :: {:ok, Schedule.t()} | {:error, term}
  defp parse_rrule(options_string, schedule, index) do
    case parse_rrule_options_string(options_string) do
      {:ok, options} ->
        rule = Rule.new(options)
        schedule = Schedule.add_recurrence_rule(schedule, rule)
        {:ok, schedule}

      {:error, term} ->
        {:error, {term, index}}
    end
  end

  @spec parse_datetime(String.t()) :: {:ok, Cocktail.time()} | {:error, term}
  defp parse_datetime(time_string) do
    case Regex.run(@time_regex, time_string) do
      [_, "", time_string] ->
        parse_naive_datetime(time_string)

      [_, "", time_string, "Z"] ->
        parse_utc_datetime(time_string)

      [_, tzid, time_string] ->
        parse_zoned_datetime(time_string, tzid)

      _ ->
        {:error, :invalid_time_format}
    end
  end

  @spec parse_naive_datetime(String.t()) :: {:ok, NaiveDateTime.t()} | {:error, term}
  defp parse_naive_datetime(time_string), do: Timex.parse(time_string, @datetime_format)

  @spec parse_utc_datetime(String.t()) :: {:ok, DateTime.t()} | {:error, term}
  defp parse_utc_datetime(time_string), do: parse_zoned_datetime(time_string, "UTC")

  @spec parse_zoned_datetime(String.t(), String.t()) :: {:ok, DateTime.t()} | {:error, term}
  defp parse_zoned_datetime(time_string, zone) do
    with {:ok, naive_datetime} <- Timex.parse(time_string, @datetime_format),
         %DateTime{} = datetime <- Timex.to_datetime(naive_datetime, zone) do
      {:ok, datetime}
    end
  end

  @spec parse_rrule_options_string(String.t()) :: {:ok, Cocktail.rule_options()} | {:error, term}
  defp parse_rrule_options_string(options_string) do
    options_string
    |> String.split(";")
    |> parse_rrule_options([])
  end

  @spec parse_rrule_options([String.t()], Cocktail.rule_options()) :: {:ok, Cocktail.rule_options()} | {:error, term}
  defp parse_rrule_options([], options), do: {:ok, options}

  defp parse_rrule_options([option_string | rest], options) do
    with {:ok, option} <- parse_rrule_option(option_string) do
      parse_rrule_options(rest, [option | options])
    end
  end

  @spec parse_rrule_option(String.t()) :: {:ok, Cocktail.rule_option()} | {:error, term}
  defp parse_rrule_option("FREQ=" <> frequency_string) do
    with {:ok, frequency} <- parse_frequency(frequency_string) do
      {:ok, {:frequency, frequency}}
    end
  end

  defp parse_rrule_option("INTERVAL=" <> interval_string) do
    with {:ok, interval} <- parse_interval(interval_string) do
      {:ok, {:interval, interval}}
    end
  end

  defp parse_rrule_option("COUNT=" <> count_string) do
    with {:ok, count} <- parse_count(count_string) do
      {:ok, {:count, count}}
    end
  end

  defp parse_rrule_option("UNTIL=" <> until_string) do
    with {:ok, until} <- parse_datetime(until_string) do
      {:ok, {:until, until}}
    end
  end

  defp parse_rrule_option("BYMONTHDAY=" <> mdays_string) do
    with {:ok, mdays} <- parse_mdays_string(mdays_string) do
      {:ok, {:days_of_month, mdays}}
    end
  end

  defp parse_rrule_option("BYDAY=" <> days_string) do
    with {:ok, days} <- parse_days_string(days_string) do
      {:ok, {:days, days |> Enum.reverse()}}
    end
  end

  defp parse_rrule_option("BYHOUR=" <> hours_string) do
    with {:ok, hours} <- parse_hours_string(hours_string) do
      {:ok, {:hours, hours |> Enum.reverse()}}
    end
  end

  defp parse_rrule_option("BYMINUTE=" <> minutes_string) do
    with {:ok, minutes} <- parse_minutes_string(minutes_string) do
      {:ok, {:minutes, minutes |> Enum.reverse()}}
    end
  end

  defp parse_rrule_option("BYSECOND=" <> seconds_string) do
    with {:ok, seconds} <- parse_seconds_string(seconds_string) do
      {:ok, {:seconds, seconds |> Enum.reverse()}}
    end
  end

  # backwards compatible parsing for schedules generated pre-0.8
  defp parse_rrule_option("BYTIME=" <> times_string), do: parse_rrule_option("X-BYTIME=" <> times_string)

  defp parse_rrule_option("X-BYTIME=" <> times_string) do
    with {:ok, times} <- parse_times_string(times_string) do
      {:ok, {:times, times |> Enum.reverse()}}
    end
  end

  defp parse_rrule_option("X-BYRANGE=" <> range_string) do
    with {:ok, time_range} <- parse_range_string(range_string) do
      {:ok, {:time_range, time_range}}
    end
  end

  defp parse_rrule_option(_), do: {:error, :unknown_rrulparam}

  @spec parse_frequency(String.t()) :: {:ok, Cocktail.frequency()} | {:error, :invalid_frequency}
  defp parse_frequency("MONTHLY"), do: {:ok, :monthly}
  defp parse_frequency("WEEKLY"), do: {:ok, :weekly}
  defp parse_frequency("DAILY"), do: {:ok, :daily}
  defp parse_frequency("HOURLY"), do: {:ok, :hourly}
  defp parse_frequency("MINUTELY"), do: {:ok, :minutely}
  defp parse_frequency("SECONDLY"), do: {:ok, :secondly}
  defp parse_frequency(_), do: {:error, :invalid_frequency}

  @spec parse_interval(String.t()) :: {:ok, pos_integer} | {:error, :invalid_interval}
  defp parse_interval(interval_string) do
    with {integer, _} <- Integer.parse(interval_string),
         {:ok, interval} <- validate_positive(integer) do
      {:ok, interval}
    else
      :error -> {:error, :invalid_interval}
    end
  end

  @spec parse_count(String.t()) :: {:ok, pos_integer} | {:error, :invalid_count}
  defp parse_count(count_string) do
    with {integer, _} <- Integer.parse(count_string),
         {:ok, count} <- validate_positive(integer) do
      {:ok, count}
    else
      :error -> {:error, :invalid_count}
    end
  end

  @spec validate_positive(integer) :: {:ok, pos_integer} | :error
  defp validate_positive(n) when n > 0, do: {:ok, n}
  defp validate_positive(_), do: :error

  @spec parse_mdays_string(String.t()) :: {:ok, [Cocktail.day_of_month()]} | {:error, :invalid_mday}
  defp parse_mdays_string(mdays_string) do
    mdays_string
    |> String.split(",")
    |> parse_mdays([])
  end

  @spec parse_mdays([String.t()], [Cocktail.day_of_month()]) :: {:ok, [Cocktail.day_of_month()]}
  defp parse_mdays([], mdays), do: {:ok, mdays}

  defp parse_mdays([mday_string | rest], mdays) do
    with {:ok, mday} <- parse_mday(mday_string) do
      parse_mdays(rest, [mday | mdays])
    end
  end

  @spec parse_mday(String.t()) :: {:ok, Cocktail.day_of_month()} | {:error, :invalid_mday}
  defp parse_mday(mday_string) do
    case Integer.parse(mday_string) do
      {mday, ""} ->
        if mday in -31..31, do: {:ok, mday}, else: {:error, :invalid_mday}

      {_mday, _remainder} ->
        {:error, :invalid_mday}

      :error ->
        {:error, :invalid_mday}
    end
  end

  @spec parse_days_string(String.t()) :: {:ok, [Cocktail.day_atom()]} | {:error, :invalid_days}
  defp parse_days_string(""), do: {:error, :invalid_days}

  defp parse_days_string(days_string) do
    days_string
    |> String.split(",")
    |> parse_days([])
  end

  @spec parse_days([String.t()], [Cocktail.day_atom()]) :: {:ok, [Cocktail.day_atom()]}
  defp parse_days([], days), do: {:ok, days}

  defp parse_days([day_string | rest], days) do
    with {:ok, day} <- parse_day(day_string) do
      parse_days(rest, [day | days])
    end
  end

  @spec parse_day(String.t()) :: {:ok, Cocktail.day_atom()} | {:error, :invalid_day}
  defp parse_day("SU"), do: {:ok, :sunday}
  defp parse_day("MO"), do: {:ok, :monday}
  defp parse_day("TU"), do: {:ok, :tuesday}
  defp parse_day("WE"), do: {:ok, :wednesday}
  defp parse_day("TH"), do: {:ok, :thursday}
  defp parse_day("FR"), do: {:ok, :friday}
  defp parse_day("SA"), do: {:ok, :saturday}
  defp parse_day(_), do: {:error, :invalid_day}

  # hour of day

  @spec parse_hours_string(String.t()) :: {:ok, [Cocktail.hour_number()]} | {:error, :invalid_hours}
  defp parse_hours_string(""), do: {:error, :invalid_hours}

  defp parse_hours_string(hours_string) do
    hours_string
    |> String.split(",")
    |> parse_hours([])
  end

  @spec parse_hours([String.t()], [Cocktail.hour_number()]) :: {:ok, [Cocktail.hour_number()]}
  defp parse_hours([], hours), do: {:ok, hours}

  defp parse_hours([hour_string | rest], hours) do
    with {:ok, hour} <- parse_hour(hour_string) do
      parse_hours(rest, [hour | hours])
    end
  end

  @spec parse_hour(String.t()) :: {:ok, Cocktail.hour_number()} | {:error, :invalid_hour}
  defp parse_hour(hour_string) do
    with {integer, _} <- Integer.parse(hour_string),
         {:ok, hour} <- validate_hour(integer) do
      {:ok, hour}
    else
      :error -> {:error, :invalid_hour}
    end
  end

  @spec validate_hour(integer) :: {:ok, Cocktail.hour_number()} | :error
  defp validate_hour(n) when n >= 0 and n < 24, do: {:ok, n}
  defp validate_hour(_), do: :error

  # minute of hour

  @spec parse_minutes_string(String.t()) :: {:ok, [Cocktail.minute_number()]} | {:error, :invalid_minutes}
  defp parse_minutes_string(""), do: {:error, :invalid_minutes}

  defp parse_minutes_string(minutes_string) do
    minutes_string
    |> String.split(",")
    |> parse_minutes([])
  end

  @spec parse_minutes([String.t()], [Cocktail.minute_number()]) :: {:ok, [Cocktail.minute_number()]}
  defp parse_minutes([], minutes), do: {:ok, minutes}

  defp parse_minutes([minute_string | rest], minutes) do
    with {:ok, minute} <- parse_minute(minute_string) do
      parse_minutes(rest, [minute | minutes])
    end
  end

  @spec parse_minute(String.t()) :: {:ok, Cocktail.minute_number()} | {:error, :invalid_minute}
  defp parse_minute(minute_string) do
    with {integer, _} <- Integer.parse(minute_string),
         {:ok, minute} <- validate_minute(integer) do
      {:ok, minute}
    else
      :error -> {:error, :invalid_minute}
    end
  end

  @spec validate_minute(integer) :: {:ok, Cocktail.minute_number()} | :error
  defp validate_minute(n) when n >= 0 and n < 60, do: {:ok, n}
  defp validate_minute(_), do: :error

  # second of minute

  @spec parse_seconds_string(String.t()) :: {:ok, [Cocktail.second_number()]} | {:error, :invalid_seconds}
  defp parse_seconds_string(""), do: {:error, :invalid_seconds}

  defp parse_seconds_string(seconds_string) do
    seconds_string
    |> String.split(",")
    |> parse_seconds([])
  end

  @spec parse_seconds([String.t()], [Cocktail.second_number()]) :: {:ok, [Cocktail.second_number()]}
  defp parse_seconds([], seconds), do: {:ok, seconds}

  defp parse_seconds([second_string | rest], seconds) do
    with {:ok, second} <- parse_second(second_string) do
      parse_seconds(rest, [second | seconds])
    end
  end

  @spec parse_second(String.t()) :: {:ok, Cocktail.second_number()} | {:error, :invalid_second}
  defp parse_second(second_string) do
    with {integer, _} <- Integer.parse(second_string),
         {:ok, second} <- validate_second(integer) do
      {:ok, second}
    else
      :error -> {:error, :invalid_second}
    end
  end

  @spec validate_second(integer) :: {:ok, Cocktail.second_number()} | :error
  defp validate_second(n) when n >= 0 and n < 60, do: {:ok, n}
  defp validate_second(_), do: :error

  # time of day

  @spec parse_times_string(String.t()) :: {:ok, [Time.t()]} | {:error, :invalid_times}
  defp parse_times_string(""), do: {:error, :invalid_times}

  defp parse_times_string(times_string) do
    times_string
    |> String.split(",")
    |> parse_times([])
  end

  @spec parse_times([String.t()], [Time.t()]) :: {:ok, [Time.t()]} | {:error, :invalid_time_format}
  defp parse_times([], times), do: {:ok, times}

  defp parse_times([time_string | rest], times) do
    with {:ok, time} <- parse_time(time_string) do
      parse_times(rest, [time | times])
    end
  end

  @spec parse_time(String.t()) :: {:ok, Time.t()} | {:error, :invalid_time_format}
  defp parse_time(time_string) do
    case Timex.parse(time_string, @time_format) do
      {:ok, datetime} -> {:ok, NaiveDateTime.to_time(datetime)}
      _error -> {:error, :invalid_time_format}
    end
  end

  # time range

  @spec parse_range_string(String.t()) :: {:ok, Cocktail.time_range()} | {:error, :invalid_time_range}
  defp parse_range_string(""), do: {:error, :invalid_time_range}

  defp parse_range_string(range_string) do
    range_string
    |> String.split(",")
    |> parse_range()
  end

  @spec parse_range([String.t()]) :: {:ok, Cocktail.time_range()}
  defp parse_range([start_time_string, end_time_string, interval_seconds_string]) do
    with {:ok, start_time} <- parse_time(start_time_string),
         {:ok, end_time} <- parse_time(end_time_string),
         {interval_seconds, _} <- Integer.parse(interval_seconds_string) do
      time_range = %{
        start_time: start_time,
        end_time: end_time,
        interval_seconds: interval_seconds
      }

      {:ok, time_range}
    else
      _ ->
        {:error, :invalid_time_range}
    end
  end

  defp parse_range(_), do: {:error, :invalid_time_range}

  # rdates and exdates

  @spec parse_rdate(String.t(), Schedule.t(), non_neg_integer) :: {:ok, Schedule.t()} | {:error, term}
  defp parse_rdate(time_string, schedule, index) do
    case parse_datetime(time_string) do
      {:ok, datetime} -> {:ok, Schedule.add_recurrence_time(schedule, datetime)}
      {:error, term} -> {:error, {term, index}}
    end
  end

  @spec parse_exdate(String.t(), Schedule.t(), non_neg_integer) :: {:ok, Schedule.t()} | {:error, term}
  defp parse_exdate(time_string, schedule, index) do
    case parse_datetime(time_string) do
      {:ok, datetime} -> {:ok, Schedule.add_exception_time(schedule, datetime)}
      {:error, term} -> {:error, {term, index}}
    end
  end
end