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