Current section
Files
Jump to
Current section
Files
lib/ex_dav/ical.ex
defmodule ExDav.ICal do
@moduledoc """
Tiny iCalendar helpers — just enough to extract the UID and primary
component (VEVENT vs VTODO) from an iCalendar object and to compute
an ETag from its serialized form.
"""
@doc """
Returns `{uid_or_nil, component_or_nil}` for the first inner component
(VEVENT or VTODO) found in the iCalendar text.
"""
@spec summarize(String.t()) :: {String.t() | nil, String.t() | nil}
def summarize(text) when is_binary(text) do
component = detect_component(text)
uid = extract_uid(text)
{uid, component}
end
defp detect_component(text) do
cond do
String.contains?(text, "BEGIN:VEVENT") -> "VEVENT"
String.contains?(text, "BEGIN:VTODO") -> "VTODO"
String.contains?(text, "BEGIN:VJOURNAL") -> "VJOURNAL"
true -> nil
end
end
defp extract_uid(text) do
text
|> unfold_lines()
|> Enum.find_value(fn line ->
case Regex.run(~r/^UID(?:;[^:]*)?:(.+)$/, String.trim_trailing(line)) do
[_, uid] -> String.trim(uid)
_ -> nil
end
end)
end
# iCalendar lines may be folded across multiple lines; a folded line
# starts with whitespace and continues the previous logical line.
defp unfold_lines(text) do
text
|> String.split(~r/\r?\n/)
|> Enum.reduce([], fn
<<c, _::binary>> = line, [prev | rest] when c in [?\s, ?\t] ->
[prev <> String.trim_leading(line) | rest]
line, acc ->
[line | acc]
end)
|> Enum.reverse()
end
@spec etag(binary()) :: String.t()
def etag(content) when is_binary(content) do
"\"" <> (:crypto.hash(:md5, content) |> Base.encode16(case: :lower)) <> "\""
end
@doc """
Returns `true` if the iCalendar object overlaps the given time range
`[range_start, range_end)` (both `DateTime.t/0`).
Uses a deliberately permissive interpretation: we extract the first
`DTSTART` and `DTEND`/`DUE` and treat them as UTC instants regardless
of `TZID`. Recurring events (RRULE) are not expanded — we test only
the master instance, which is good enough for the minimal server.
"""
@spec overlaps_range?(String.t(), DateTime.t(), DateTime.t()) :: boolean()
def overlaps_range?(text, range_start, range_end) do
lines = unfold_lines(text)
obj_start = first_datetime(lines, "DTSTART")
obj_end = first_datetime(lines, "DTEND") || first_datetime(lines, "DUE")
cond do
is_nil(obj_start) and is_nil(obj_end) ->
true
is_nil(obj_start) ->
DateTime.compare(obj_end, range_start) != :lt
is_nil(obj_end) ->
DateTime.compare(obj_start, range_end) == :lt
true ->
DateTime.compare(obj_start, range_end) == :lt and
DateTime.compare(obj_end, range_start) != :lt
end
end
defp first_datetime(lines, prop) do
Enum.find_value(lines, fn line ->
case Regex.run(~r/^#{prop}(?:;[^:]*)?:(.+)$/, String.trim_trailing(line)) do
[_, value] -> parse_ical_datetime(String.trim(value))
_ -> nil
end
end)
end
@doc """
Parse a CalDAV `time-range` start/end attribute (basic ISO 8601 form
used by CalDAV: `YYYYMMDDTHHMMSSZ`) or an iCalendar property value into
a `DateTime`. Returns `nil` on failure.
"""
def parse_ical_datetime(value) when is_binary(value) do
cond do
Regex.match?(~r/^\d{8}T\d{6}Z$/, value) ->
<<y::binary-4, mo::binary-2, d::binary-2, "T", h::binary-2, mi::binary-2, s::binary-2,
"Z">> = value
DateTime.new!(
Date.new!(int(y), int(mo), int(d)),
Time.new!(int(h), int(mi), int(s)),
"Etc/UTC"
)
Regex.match?(~r/^\d{8}T\d{6}$/, value) ->
<<y::binary-4, mo::binary-2, d::binary-2, "T", h::binary-2, mi::binary-2,
s::binary-2>> = value
DateTime.new!(
Date.new!(int(y), int(mo), int(d)),
Time.new!(int(h), int(mi), int(s)),
"Etc/UTC"
)
Regex.match?(~r/^\d{8}$/, value) ->
<<y::binary-4, mo::binary-2, d::binary-2>> = value
DateTime.new!(
Date.new!(int(y), int(mo), int(d)),
Time.new!(0, 0, 0),
"Etc/UTC"
)
true ->
nil
end
end
def parse_ical_datetime(_), do: nil
defp int(s), do: String.to_integer(s)
end