Skip to main content

lib/caldav_ex/event.ex

defmodule CalDAVEx.Event do
  @moduledoc """
  Event operations including listing, retrieval, and CRUD.
  """

  alias CalDAVEx.{HTTP, Types.Event, XML}

  def list(client, calendar_url, opts \\ []) do
    xml = calendar_query(opts)

    case HTTP.request(client, :report, calendar_url, [{"depth", "1"}], xml) do
      {:ok, %{body: body}} ->
        with {:ok, responses} <- XML.parse_multistatus(body, client.config.base_url) do
          events =
            responses
            |> Enum.filter(& &1.calendar_data)
            |> Enum.map(&build_event/1)

          {:ok, events}
        end

      error ->
        error
    end
  end

  def get(client, event_url) do
    case HTTP.request(client, :get, event_url) do
      {:ok, %{body: body, headers: headers}} ->
        etag = get_header(headers, "etag")
        {:ok, %Event{href: event_url, etag: etag, calendar_data: body}}

      error ->
        error
    end
  end

  def create(client, calendar_url, filename, ics_data) do
    url = String.trim_trailing(calendar_url, "/") <> "/" <> filename
    headers = [{"if-none-match", "*"}]

    case HTTP.request(client, :put, url, headers, ics_data) do
      {:ok, _} -> {:ok, %Event{href: url}}
      error -> error
    end
  end

  def update(client, event_url, ics_data, etag) do
    headers = if etag, do: [{"if-match", etag}], else: []
    HTTP.request(client, :put, event_url, headers, ics_data)
  end

  def delete(client, event_url, etag) do
    headers = if etag, do: [{"if-match", etag}], else: []
    HTTP.request(client, :delete, event_url, headers)
  end

  defp calendar_query(opts) do
    time_range = time_range(Keyword.get(opts, :from), Keyword.get(opts, :to))

    """
    <?xml version="1.0" encoding="UTF-8"?>
    <C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
      <D:prop>
        <D:getetag/>
        <C:calendar-data/>
      </D:prop>
      <C:filter>
        <C:comp-filter name="VCALENDAR">
          <C:comp-filter name="VEVENT">#{time_range}</C:comp-filter>
        </C:comp-filter>
      </C:filter>
    </C:calendar-query>
    """
  end

  defp time_range(nil, nil), do: ""

  defp time_range(from, to) do
    start_attr = if from, do: " start=\"#{format_caldav_datetime(from)}\"", else: ""
    end_attr = if to, do: " end=\"#{format_caldav_datetime(to)}\"", else: ""
    "<C:time-range#{start_attr}#{end_attr}/>"
  end

  defp format_caldav_datetime(%DateTime{} = datetime) do
    datetime
    |> DateTime.shift_zone!("Etc/UTC")
    |> Calendar.strftime("%Y%m%dT%H%M%SZ")
  end

  defp build_event(response) do
    parsed = parse_ics(response.calendar_data)

    %Event{
      href: response.href,
      etag: response.etag,
      calendar_data: response.calendar_data,
      summary: parsed.summary,
      dtstart: parsed.dtstart,
      dtend: parsed.dtend,
      uid: parsed.uid,
      description: parsed.description,
      location: parsed.location,
      status: parsed.status,
      rrule: parsed.rrule,
      organizer: parsed.organizer,
      attendees: parsed.attendees
    }
  end

  defp parse_ics(calendar_data) do
    case parse_calendar(calendar_data) do
      %ICal{events: [event | _]} ->
        extract_event_fields(event)

      _ ->
        empty_event_fields()
    end
  end

  defp extract_event_fields(event) do
    %{
      summary: event.summary,
      dtstart: event.dtstart,
      dtend: event.dtend,
      uid: event.uid,
      description: event.description,
      location: event.location,
      status: extract_status(event),
      rrule: extract_rrule(event),
      organizer: extract_organizer(event),
      attendees: extract_attendees(event)
    }
  end

  defp empty_event_fields do
    %{
      summary: nil,
      dtstart: nil,
      dtend: nil,
      uid: nil,
      description: nil,
      location: nil,
      status: nil,
      rrule: nil,
      organizer: nil,
      attendees: []
    }
  end

  defp extract_rrule(%{rrule: %ICal.Recurrence{} = rrule}), do: format_rrule(rrule)
  defp extract_rrule(_), do: nil

  defp format_rrule(%ICal.Recurrence{} = rrule) do
    []
    |> add_frequency(rrule.frequency)
    |> add_interval(rrule.interval)
    |> add_count(rrule.count)
    |> add_until(rrule.until)
    |> add_by_day(rrule.by_day)
    |> add_by_month_day(rrule.by_month_day)
    |> add_by_month(rrule.by_month)
    |> Enum.reverse()
    |> Enum.join(";")
  end

  defp add_frequency(parts, nil), do: parts

  defp add_frequency(parts, frequency),
    do: ["FREQ=#{String.upcase(to_string(frequency))}" | parts]

  defp add_interval(parts, nil), do: parts
  defp add_interval(parts, 1), do: parts
  defp add_interval(parts, interval), do: ["INTERVAL=#{interval}" | parts]

  defp add_count(parts, nil), do: parts
  defp add_count(parts, count), do: ["COUNT=#{count}" | parts]

  defp add_until(parts, nil), do: parts
  defp add_until(parts, until), do: ["UNTIL=#{format_until(until)}" | parts]

  defp add_by_day(parts, nil), do: parts
  defp add_by_day(parts, []), do: parts
  defp add_by_day(parts, by_day), do: ["BYDAY=#{format_by_day(by_day)}" | parts]

  defp add_by_month_day(parts, nil), do: parts

  defp add_by_month_day(parts, by_month_day),
    do: ["BYMONTHDAY=#{Enum.join(by_month_day, ",")}" | parts]

  defp add_by_month(parts, nil), do: parts
  defp add_by_month(parts, by_month), do: ["BYMONTH=#{Enum.join(by_month, ",")}" | parts]

  defp format_until(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y%m%dT%H%M%SZ")
  defp format_until(%Date{} = d), do: Calendar.strftime(d, "%Y%m%d")
  defp format_until(_), do: ""

  defp format_by_day(by_day) do
    Enum.map_join(by_day, ",", fn
      {0, day} -> String.upcase(to_string(day)) |> String.slice(0, 2)
      {n, day} -> "#{n}#{String.upcase(to_string(day)) |> String.slice(0, 2)}"
    end)
  end

  defp extract_status(%{status: status}) when is_atom(status) and not is_nil(status) do
    status |> to_string() |> String.upcase()
  end

  defp extract_status(_), do: nil

  defp extract_organizer(%{organizer: organizer})
       when is_binary(organizer) and not is_nil(organizer) do
    organizer
  end

  defp extract_organizer(_), do: nil

  defp extract_attendees(%{attendees: attendees}) do
    Enum.map(attendees, fn
      %ICal.Attendee{name: name} -> name
      name when is_binary(name) -> name
      _ -> nil
    end)
    |> Enum.reject(&is_nil/1)
  end

  defp parse_calendar(calendar_data) do
    ICal.from_ics(calendar_data)
  rescue
    _ -> nil
  end

  defp get_header(headers, key) do
    headers
    |> Enum.find_value(fn {k, v} -> if String.downcase(k) == key, do: v end)
    |> normalize_header_value()
  end

  defp normalize_header_value([value | _]), do: value
  defp normalize_header_value(value) when is_binary(value), do: value
  defp normalize_header_value(_), do: nil
end